switchroom 0.15.25 → 0.15.26

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.
@@ -50477,8 +50477,8 @@ var {
50477
50477
  } = import__.default;
50478
50478
 
50479
50479
  // src/build-info.ts
50480
- var VERSION = "0.15.25";
50481
- var COMMIT_SHA = "0d066743";
50480
+ var VERSION = "0.15.26";
50481
+ var COMMIT_SHA = "497e0d23";
50482
50482
 
50483
50483
  // src/cli/agent.ts
50484
50484
  init_source();
@@ -67478,6 +67478,23 @@ function addWebhookSource(yamlText, agentName, source) {
67478
67478
  }
67479
67479
  return String(doc);
67480
67480
  }
67481
+ function addAgentSecret(yamlText, agentName, key) {
67482
+ const doc = import_yaml11.parseDocument(yamlText);
67483
+ ensureAgent(doc, agentName);
67484
+ const existing = doc.getIn(["agents", agentName, "secrets"]);
67485
+ if (import_yaml11.isSeq(existing)) {
67486
+ const seq = existing;
67487
+ for (const item of seq.items) {
67488
+ const v = item.value ?? item;
67489
+ if (v === key)
67490
+ return yamlText;
67491
+ }
67492
+ seq.add(key);
67493
+ } else {
67494
+ doc.setIn(["agents", agentName, "secrets"], [key]);
67495
+ }
67496
+ return String(doc);
67497
+ }
67481
67498
  function removeWebhookSource(yamlText, agentName, source) {
67482
67499
  const doc = import_yaml11.parseDocument(yamlText);
67483
67500
  if (!hasAgent(doc, agentName))
@@ -67860,6 +67877,22 @@ async function vaultPut(program3, key, value) {
67860
67877
  setStringSecret(passphrase, vaultPath, key, value);
67861
67878
  console.log(source_default.green(`\u2713 Stored secret in vault as '${key}'`));
67862
67879
  }
67880
+ async function vaultPutQuiet(program3, key, value) {
67881
+ const configPath = program3.optsWithGlobals().config ?? undefined;
67882
+ const vaultPath = resolveVaultPath(configPath);
67883
+ const passphrase = await getVaultPassphrase();
67884
+ if (!existsSync43(vaultPath))
67885
+ createVault(passphrase, vaultPath);
67886
+ setStringSecret(passphrase, vaultPath, key, value);
67887
+ }
67888
+ async function vaultGet(program3, key) {
67889
+ const configPath = program3.optsWithGlobals().config ?? undefined;
67890
+ const vaultPath = resolveVaultPath(configPath);
67891
+ if (!existsSync43(vaultPath))
67892
+ return null;
67893
+ const passphrase = await getVaultPassphrase();
67894
+ return getStringSecret(passphrase, vaultPath, key);
67895
+ }
67863
67896
  function resolveVaultPath(configPath) {
67864
67897
  try {
67865
67898
  const config = loadConfig(configPath);
@@ -67988,9 +68021,118 @@ function promptHidden2(prompt) {
67988
68021
  init_source();
67989
68022
  init_helpers();
67990
68023
  import { readFileSync as readFileSync39, writeFileSync as writeFileSync22 } from "node:fs";
68024
+
68025
+ // src/linear/oauth-refresh.ts
68026
+ var LINEAR_TOKEN_ENDPOINT = "https://api.linear.app/oauth/token";
68027
+ var DEFAULT_REFRESH_SKEW_SEC = 2 * 3600;
68028
+ async function refreshLinearAppToken(bundle, opts = {}) {
68029
+ const fetchImpl = opts.fetchImpl ?? fetch;
68030
+ const nowSec = opts.nowSec ?? (() => Math.floor(Date.now() / 1000));
68031
+ const form = new URLSearchParams({
68032
+ grant_type: "refresh_token",
68033
+ refresh_token: bundle.refreshToken,
68034
+ client_id: bundle.clientId,
68035
+ client_secret: bundle.clientSecret
68036
+ });
68037
+ let resp;
68038
+ try {
68039
+ resp = await fetchImpl(LINEAR_TOKEN_ENDPOINT, {
68040
+ method: "POST",
68041
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
68042
+ body: form.toString()
68043
+ });
68044
+ } catch (err) {
68045
+ return { ok: false, reason: "network", detail: err.message };
68046
+ }
68047
+ if (!resp.ok) {
68048
+ const txt = await resp.text().catch(() => "");
68049
+ const revoked = resp.status === 400 || /invalid_grant|invalid_token/i.test(txt);
68050
+ return {
68051
+ ok: false,
68052
+ reason: revoked ? "revoked" : "http_error",
68053
+ detail: `HTTP ${resp.status}${txt ? ` ${txt.slice(0, 200)}` : ""}`
68054
+ };
68055
+ }
68056
+ let json;
68057
+ try {
68058
+ json = await resp.json();
68059
+ } catch {
68060
+ return { ok: false, reason: "bad_response", detail: "non-JSON token response" };
68061
+ }
68062
+ const accessToken = json.access_token;
68063
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
68064
+ return { ok: false, reason: "bad_response", detail: "no access_token in response" };
68065
+ }
68066
+ const expiresIn = typeof json.expires_in === "number" ? json.expires_in : 86400;
68067
+ const rotated = typeof json.refresh_token === "string" && json.refresh_token.length > 0 ? json.refresh_token : bundle.refreshToken;
68068
+ return {
68069
+ ok: true,
68070
+ accessToken,
68071
+ refreshToken: rotated,
68072
+ expiresAt: nowSec() + expiresIn,
68073
+ ...typeof json.scope === "string" ? { scope: json.scope } : {}
68074
+ };
68075
+ }
68076
+ function parseBundle(raw) {
68077
+ if (raw == null || raw === "")
68078
+ return null;
68079
+ let o;
68080
+ try {
68081
+ o = JSON.parse(raw);
68082
+ } catch {
68083
+ return null;
68084
+ }
68085
+ if (typeof o.client_id === "string" && typeof o.client_secret === "string" && typeof o.refresh_token === "string" && o.client_id.length > 0 && o.client_secret.length > 0 && o.refresh_token.length > 0) {
68086
+ return {
68087
+ clientId: o.client_id,
68088
+ clientSecret: o.client_secret,
68089
+ refreshToken: o.refresh_token,
68090
+ ...typeof o.expires_at === "number" ? { expiresAt: o.expires_at } : {}
68091
+ };
68092
+ }
68093
+ return null;
68094
+ }
68095
+ function serializeBundle(b) {
68096
+ return JSON.stringify({
68097
+ client_id: b.clientId,
68098
+ client_secret: b.clientSecret,
68099
+ refresh_token: b.refreshToken,
68100
+ ...b.expiresAt != null ? { expires_at: b.expiresAt } : {}
68101
+ });
68102
+ }
68103
+ async function performLinearRefresh(io) {
68104
+ const raw = await io.readBundle();
68105
+ const bundle = parseBundle(raw);
68106
+ if (!bundle) {
68107
+ return { ok: false, reason: "no_bundle", detail: "no/invalid refresh bundle" };
68108
+ }
68109
+ const res = await refreshLinearAppToken(bundle, {
68110
+ ...io.fetchImpl ? { fetchImpl: io.fetchImpl } : {},
68111
+ ...io.nowSec ? { nowSec: io.nowSec } : {}
68112
+ });
68113
+ if (!res.ok)
68114
+ return { ok: false, reason: res.reason, detail: res.detail };
68115
+ try {
68116
+ await io.writeBundle(serializeBundle({
68117
+ clientId: bundle.clientId,
68118
+ clientSecret: bundle.clientSecret,
68119
+ refreshToken: res.refreshToken,
68120
+ expiresAt: res.expiresAt
68121
+ }));
68122
+ await io.writeToken(res.accessToken);
68123
+ } catch (err) {
68124
+ return { ok: false, reason: "persist_failed", detail: err.message };
68125
+ }
68126
+ return { ok: true, accessToken: res.accessToken, expiresAt: res.expiresAt };
68127
+ }
68128
+
68129
+ // src/cli/linear-agent.ts
68130
+ function bundleKeyFor(agent) {
68131
+ return `linear/${agent}/oauth`;
68132
+ }
67991
68133
  function registerLinearAgentCommand(program3) {
67992
68134
  const linear = program3.command("linear-agent").description("Install an agent into a Linear workspace as a first-class app actor (#2298) \u2014 @-mentionable, delegate-assignable, agent sessions wake it instantly.");
67993
- linear.command("setup").description("Provision <agent> as a Linear agent. Vault-stores the Linear OAuth app token (actor=app) under 'linear/<agent>/token' and enables the linear_agent block in switchroom.yaml. The OAuth browser authorize step is printed as instructions (it can't run headless); pass the already-obtained --token.").requiredOption("--agent <name>", "Agent name (must exist in switchroom.yaml)").requiredOption("--token <token>", "The Linear OAuth app token (actor=app), obtained out-of-band via the browser authorize step. Stored in the vault, never in switchroom.yaml.").option("--client-id <id>", "Linear OAuth app client id (for the printed authorize-URL hint).").option("--client-secret <secret>", "Linear OAuth app client secret (informational \u2014 not stored by this verb).").option("--redirect-uri <uri>", "OAuth redirect URI registered on the Linear app (for the authorize-URL hint).").option("--workspace-id <id>", "Optional Linear workspace (organization) id to record in config.").option("--webhook-base <url>", "Base URL of the switchroom web server (e.g. https://hooks.switchroom.ai). Used to print the webhook URL to register in Linear. Defaults to a placeholder.").option("--dry-run", "Print the YAML diff + instructions without writing or vaulting anything").action(withConfigError(async (opts) => {
68135
+ linear.command("setup").description("Provision <agent> as a Linear agent. Vault-stores the Linear OAuth app token (actor=app) under 'linear/<agent>/token' and enables the linear_agent block in switchroom.yaml. The OAuth browser authorize step is printed as instructions (it can't run headless); pass the already-obtained --token.").requiredOption("--agent <name>", "Agent name (must exist in switchroom.yaml)").requiredOption("--token <token>", "The Linear OAuth app token (actor=app), obtained out-of-band via the browser authorize step. Stored in the vault, never in switchroom.yaml.").option("--client-id <id>", "Linear OAuth app client id. Stored (with --client-secret + --refresh-token) to enable unattended token refresh.").option("--client-secret <secret>", "Linear OAuth app client secret. Stored in the vault (with --client-id + --refresh-token) so the token can be refreshed without a browser re-auth.").option("--refresh-token <token>", "The refresh_token from the OAuth exchange. Stored so an expired access token self-heals (see 'linear-agent refresh').").option("--token-expires-in <seconds>", "expires_in from the OAuth token response (seconds). Records when the access token expires so refresh runs proactively. Defaults to 86400.").option("--redirect-uri <uri>", "OAuth redirect URI registered on the Linear app (for the authorize-URL hint).").option("--workspace-id <id>", "Optional Linear workspace (organization) id to record in config.").option("--webhook-base <url>", "Base URL of the switchroom web server (e.g. https://hooks.switchroom.ai). Used to print the webhook URL to register in Linear. Defaults to a placeholder.").option("--dry-run", "Print the YAML diff + instructions without writing or vaulting anything").action(withConfigError(async (opts) => {
67994
68136
  if (!/^[a-z][a-z0-9_-]{0,63}$/.test(opts.agent)) {
67995
68137
  fail2(`--agent must be a lowercase agent slug (got '${opts.agent}').`);
67996
68138
  }
@@ -67998,10 +68140,26 @@ function registerLinearAgentCommand(program3) {
67998
68140
  fail2("--token must be a non-empty Linear app token.");
67999
68141
  }
68000
68142
  const vaultKey = `linear/${opts.agent}/token`;
68143
+ const bundleKey = bundleKeyFor(opts.agent);
68144
+ const canRefresh = Boolean(opts.refreshToken && opts.clientId && opts.clientSecret);
68001
68145
  if (!opts.dryRun) {
68002
68146
  await vaultPut(program3, vaultKey, opts.token);
68147
+ if (canRefresh) {
68148
+ const expiresIn = Number.parseInt(opts.tokenExpiresIn ?? "", 10);
68149
+ const ttl = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 86400;
68150
+ const bundle = serializeBundle({
68151
+ clientId: opts.clientId,
68152
+ clientSecret: opts.clientSecret,
68153
+ refreshToken: opts.refreshToken,
68154
+ expiresAt: Math.floor(Date.now() / 1000) + ttl
68155
+ });
68156
+ await vaultPutQuiet(program3, bundleKey, bundle);
68157
+ }
68003
68158
  } else {
68004
68159
  console.log(source_default.gray(`[dry-run] would store the Linear token in the vault as '${vaultKey}'`));
68160
+ if (canRefresh) {
68161
+ console.log(source_default.gray(`[dry-run] would store the refresh bundle as '${bundleKey}' (enables auto-refresh)`));
68162
+ }
68005
68163
  }
68006
68164
  const path4 = getConfigPath(program3);
68007
68165
  const before = readFileSync39(path4, "utf-8");
@@ -68011,6 +68169,10 @@ function registerLinearAgentCommand(program3) {
68011
68169
  token: `vault:${vaultKey}`,
68012
68170
  ...opts.workspaceId ? { workspaceId: opts.workspaceId } : {}
68013
68171
  });
68172
+ if (canRefresh) {
68173
+ after = addAgentSecret(after, opts.agent, bundleKey);
68174
+ after = addAgentSecret(after, opts.agent, vaultKey);
68175
+ }
68014
68176
  } catch (err) {
68015
68177
  fail2(err.message);
68016
68178
  }
@@ -68021,10 +68183,42 @@ function registerLinearAgentCommand(program3) {
68021
68183
  writeFileSync22(path4, after, "utf-8");
68022
68184
  console.log(source_default.green(`\u2713 Enabled linear-agent for agent '${opts.agent}'`));
68023
68185
  console.log(source_default.gray(` Vault key: ${vaultKey}`));
68186
+ if (canRefresh) {
68187
+ console.log(source_default.green(`\u2713 Auto-refresh enabled \u2014 refresh bundle stored at '${bundleKey}'`));
68188
+ console.log(source_default.gray(` Granted ACL: '${bundleKey}' + '${vaultKey}' added to agents.${opts.agent}.secrets[] (agent rotates them in-container on a 401).`));
68189
+ } else {
68190
+ console.log(source_default.yellow(`\u26a0 No refresh bundle stored \u2014 the access token will expire (~24h) and need a manual re-auth.`));
68191
+ console.log(source_default.gray(` To enable auto-refresh, re-run with --refresh-token <rt> --client-id <id> --client-secret <secret> --token-expires-in <sec>.`));
68192
+ }
68024
68193
  console.log(source_default.gray(` Run 'switchroom agent restart ${opts.agent}' to pick up the change.`));
68025
68194
  }
68026
68195
  printLinearInstructions(opts, vaultKey);
68027
68196
  }));
68197
+ linear.command("refresh").description("Refresh <agent>'s Linear app token using the stored refresh bundle (linear/<agent>/oauth). Exchanges the refresh_token for a fresh access token, writes it to linear/<agent>/token, and rotates the stored refresh_token + expiry. Use to recover an expired token or seed automation. Host-side write \u2014 the running agent picks it up on its next broker re-mirror / restart.").requiredOption("--agent <name>", "Agent name (must have a linear_agent block)").action(withConfigError(async (opts) => {
68198
+ if (!/^[a-z][a-z0-9_-]{0,63}$/.test(opts.agent)) {
68199
+ fail2(`--agent must be a lowercase agent slug (got '${opts.agent}').`);
68200
+ }
68201
+ const bundleKey = bundleKeyFor(opts.agent);
68202
+ const res = await performLinearRefresh({
68203
+ readBundle: () => vaultGet(program3, bundleKey),
68204
+ writeToken: (t) => vaultPutQuiet(program3, `linear/${opts.agent}/token`, t),
68205
+ writeBundle: (json) => vaultPutQuiet(program3, bundleKey, json)
68206
+ });
68207
+ if (!res.ok) {
68208
+ if (res.reason === "no_bundle") {
68209
+ fail2(`No refresh bundle at '${bundleKey}'. Provision one via 'linear-agent setup --agent ${opts.agent} ` + `--token <t> --refresh-token <rt> --client-id <id> --client-secret <secret>'.`);
68210
+ }
68211
+ if (res.reason === "revoked") {
68212
+ fail2(`Refresh token is dead (revoked/expired) \u2014 re-authorize in a browser (actor=app) and re-run setup with the new --refresh-token. (${res.detail})`);
68213
+ }
68214
+ fail2(`Refresh failed (${res.reason}): ${res.detail}`);
68215
+ }
68216
+ if (res.ok) {
68217
+ const hours = Math.max(1, Math.round((res.expiresAt - Date.now() / 1000) / 3600));
68218
+ console.log(source_default.green(`\u2713 Refreshed Linear token for '${opts.agent}' (expires in ~${hours}h).`));
68219
+ console.log(source_default.gray(` Written to vault:linear/${opts.agent}/token (+ rotated bundle). Restart the agent or wait for the broker to re-mirror.`));
68220
+ }
68221
+ }));
68028
68222
  linear.command("set-team").description("Set (or clear) the default Linear team captured issues file into for <agent>. Only needed when the workspace has multiple teams \u2014 a single-team workspace auto-resolves. Pass --clear to remove the default.").requiredOption("--agent <name>", "Agent name (must have a linear_agent block)").option("--team <id>", "Linear team id new captured issues default to.").option("--clear", "Remove the configured default team (revert to auto-resolve).").action(withConfigError(async (opts) => {
68029
68223
  if (!/^[a-z][a-z0-9_-]{0,63}$/.test(opts.agent)) {
68030
68224
  fail2(`--agent must be a lowercase agent slug (got '${opts.agent}').`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.25",
3
+ "version": "0.15.26",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54424,10 +54424,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
54424
54424
  }
54425
54425
 
54426
54426
  // ../src/build-info.ts
54427
- var VERSION = "0.15.25";
54428
- var COMMIT_SHA = "0d066743";
54429
- var COMMIT_DATE = "2026-06-15T02:15:36Z";
54430
- var LATEST_PR = 2359;
54427
+ var VERSION = "0.15.26";
54428
+ var COMMIT_SHA = "497e0d23";
54429
+ var COMMIT_DATE = "2026-06-15T03:00:42Z";
54430
+ var LATEST_PR = 2362;
54431
54431
  var COMMITS_AHEAD_OF_TAG = 0;
54432
54432
 
54433
54433
  // gateway/boot-version.ts
@@ -54720,6 +54720,112 @@ async function revokeGrantViaBroker(id, opts) {
54720
54720
 
54721
54721
  // gateway/linear-activity.ts
54722
54722
  init_client2();
54723
+
54724
+ // ../src/linear/oauth-refresh.ts
54725
+ var LINEAR_TOKEN_ENDPOINT = "https://api.linear.app/oauth/token";
54726
+ var DEFAULT_REFRESH_SKEW_SEC = 2 * 3600;
54727
+ async function refreshLinearAppToken(bundle, opts = {}) {
54728
+ const fetchImpl = opts.fetchImpl ?? fetch;
54729
+ const nowSec = opts.nowSec ?? (() => Math.floor(Date.now() / 1000));
54730
+ const form = new URLSearchParams({
54731
+ grant_type: "refresh_token",
54732
+ refresh_token: bundle.refreshToken,
54733
+ client_id: bundle.clientId,
54734
+ client_secret: bundle.clientSecret
54735
+ });
54736
+ let resp;
54737
+ try {
54738
+ resp = await fetchImpl(LINEAR_TOKEN_ENDPOINT, {
54739
+ method: "POST",
54740
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
54741
+ body: form.toString()
54742
+ });
54743
+ } catch (err) {
54744
+ return { ok: false, reason: "network", detail: err.message };
54745
+ }
54746
+ if (!resp.ok) {
54747
+ const txt = await resp.text().catch(() => "");
54748
+ const revoked = resp.status === 400 || /invalid_grant|invalid_token/i.test(txt);
54749
+ return {
54750
+ ok: false,
54751
+ reason: revoked ? "revoked" : "http_error",
54752
+ detail: `HTTP ${resp.status}${txt ? ` ${txt.slice(0, 200)}` : ""}`
54753
+ };
54754
+ }
54755
+ let json;
54756
+ try {
54757
+ json = await resp.json();
54758
+ } catch {
54759
+ return { ok: false, reason: "bad_response", detail: "non-JSON token response" };
54760
+ }
54761
+ const accessToken = json.access_token;
54762
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
54763
+ return { ok: false, reason: "bad_response", detail: "no access_token in response" };
54764
+ }
54765
+ const expiresIn = typeof json.expires_in === "number" ? json.expires_in : 86400;
54766
+ const rotated = typeof json.refresh_token === "string" && json.refresh_token.length > 0 ? json.refresh_token : bundle.refreshToken;
54767
+ return {
54768
+ ok: true,
54769
+ accessToken,
54770
+ refreshToken: rotated,
54771
+ expiresAt: nowSec() + expiresIn,
54772
+ ...typeof json.scope === "string" ? { scope: json.scope } : {}
54773
+ };
54774
+ }
54775
+ function parseBundle(raw) {
54776
+ if (raw == null || raw === "")
54777
+ return null;
54778
+ let o;
54779
+ try {
54780
+ o = JSON.parse(raw);
54781
+ } catch {
54782
+ return null;
54783
+ }
54784
+ if (typeof o.client_id === "string" && typeof o.client_secret === "string" && typeof o.refresh_token === "string" && o.client_id.length > 0 && o.client_secret.length > 0 && o.refresh_token.length > 0) {
54785
+ return {
54786
+ clientId: o.client_id,
54787
+ clientSecret: o.client_secret,
54788
+ refreshToken: o.refresh_token,
54789
+ ...typeof o.expires_at === "number" ? { expiresAt: o.expires_at } : {}
54790
+ };
54791
+ }
54792
+ return null;
54793
+ }
54794
+ function serializeBundle(b) {
54795
+ return JSON.stringify({
54796
+ client_id: b.clientId,
54797
+ client_secret: b.clientSecret,
54798
+ refresh_token: b.refreshToken,
54799
+ ...b.expiresAt != null ? { expires_at: b.expiresAt } : {}
54800
+ });
54801
+ }
54802
+ async function performLinearRefresh(io) {
54803
+ const raw = await io.readBundle();
54804
+ const bundle = parseBundle(raw);
54805
+ if (!bundle) {
54806
+ return { ok: false, reason: "no_bundle", detail: "no/invalid refresh bundle" };
54807
+ }
54808
+ const res = await refreshLinearAppToken(bundle, {
54809
+ ...io.fetchImpl ? { fetchImpl: io.fetchImpl } : {},
54810
+ ...io.nowSec ? { nowSec: io.nowSec } : {}
54811
+ });
54812
+ if (!res.ok)
54813
+ return { ok: false, reason: res.reason, detail: res.detail };
54814
+ try {
54815
+ await io.writeBundle(serializeBundle({
54816
+ clientId: bundle.clientId,
54817
+ clientSecret: bundle.clientSecret,
54818
+ refreshToken: res.refreshToken,
54819
+ expiresAt: res.expiresAt
54820
+ }));
54821
+ await io.writeToken(res.accessToken);
54822
+ } catch (err) {
54823
+ return { ok: false, reason: "persist_failed", detail: err.message };
54824
+ }
54825
+ return { ok: true, accessToken: res.accessToken, expiresAt: res.expiresAt };
54826
+ }
54827
+
54828
+ // gateway/linear-activity.ts
54723
54829
  var LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql";
54724
54830
  async function defaultResolveLinearToken(agent) {
54725
54831
  const key = `linear/${agent}/token`;
@@ -54736,6 +54842,53 @@ async function defaultResolveLinearToken(agent) {
54736
54842
  return { ok: false, reason: "denied" };
54737
54843
  return { ok: false, reason: "unknown" };
54738
54844
  }
54845
+ function brokerRefreshIO(agent, fetchImpl) {
54846
+ const token = readVaultTokenFile(agent) ?? undefined;
54847
+ const opt = token ? { token } : {};
54848
+ return {
54849
+ readBundle: async () => {
54850
+ const r = await getViaBrokerStructured(`linear/${agent}/oauth`, opt);
54851
+ return r.kind === "ok" && r.entry.kind === "string" ? r.entry.value : null;
54852
+ },
54853
+ writeToken: async (t) => {
54854
+ const r = await putViaBroker(`linear/${agent}/token`, { kind: "string", value: t }, opt);
54855
+ if (r.kind !== "ok")
54856
+ throw new Error(`broker put linear/${agent}/token: ${r.kind}`);
54857
+ },
54858
+ writeBundle: async (j) => {
54859
+ const r = await putViaBroker(`linear/${agent}/oauth`, { kind: "string", value: j }, opt);
54860
+ if (r.kind !== "ok")
54861
+ throw new Error(`broker put linear/${agent}/oauth: ${r.kind}`);
54862
+ },
54863
+ ...fetchImpl ? { fetchImpl } : {}
54864
+ };
54865
+ }
54866
+ async function linearPostWithRefresh(body, token, agent, fetchImpl, log, refreshIO) {
54867
+ const post = (t) => fetchImpl(LINEAR_GRAPHQL_ENDPOINT, {
54868
+ method: "POST",
54869
+ headers: { "Content-Type": "application/json", Authorization: t },
54870
+ body
54871
+ });
54872
+ let resp = await post(token);
54873
+ if (resp.status !== 401)
54874
+ return { resp, token };
54875
+ const io = (refreshIO ?? ((a) => brokerRefreshIO(a, fetchImpl)))(agent);
54876
+ const refreshed = await performLinearRefresh({ ...io, fetchImpl });
54877
+ if (!refreshed.ok) {
54878
+ if (refreshed.reason === "revoked") {
54879
+ log(`telegram gateway: linear token REVOKED agent=${agent} \u2014 refresh token is dead; ` + `operator must re-authorize (linear-agent setup --refresh-token \u2026)
54880
+ `);
54881
+ } else {
54882
+ log(`telegram gateway: linear token refresh failed agent=${agent} reason=${refreshed.reason}
54883
+ `);
54884
+ }
54885
+ return { resp, token };
54886
+ }
54887
+ log(`telegram gateway: linear token auto-refreshed agent=${agent} (was 401)
54888
+ `);
54889
+ resp = await post(refreshed.accessToken);
54890
+ return { resp, token: refreshed.accessToken };
54891
+ }
54739
54892
  async function emitLinearAgentActivity(args, deps = {}) {
54740
54893
  const log = deps.log ?? ((s) => process.stderr.write(s));
54741
54894
  const sessionId = args.agent_session_id;
@@ -54780,14 +54933,7 @@ async function emitLinearAgentActivity(args, deps = {}) {
54780
54933
  const fetchImpl = deps.fetchImpl ?? fetch;
54781
54934
  let resp;
54782
54935
  try {
54783
- resp = await fetchImpl(LINEAR_GRAPHQL_ENDPOINT, {
54784
- method: "POST",
54785
- headers: {
54786
- "Content-Type": "application/json",
54787
- Authorization: tokenResult.token
54788
- },
54789
- body: JSON.stringify({ query: mutation, variables })
54790
- });
54936
+ ({ resp } = await linearPostWithRefresh(JSON.stringify({ query: mutation, variables }), tokenResult.token, agent, fetchImpl, log, deps.refreshIO));
54791
54937
  } catch (err) {
54792
54938
  return {
54793
54939
  content: [{ type: "text", text: `linear_agent_activity failed: request error: ${err.message}` }]
@@ -54847,15 +54993,13 @@ async function createLinearIssue(args, deps = {}) {
54847
54993
  ]
54848
54994
  };
54849
54995
  }
54850
- const token = tokenResult.token;
54996
+ let activeToken = tokenResult.token;
54851
54997
  const gql = async (query2, variables) => {
54852
54998
  let resp;
54853
54999
  try {
54854
- resp = await fetchImpl(LINEAR_GRAPHQL_ENDPOINT, {
54855
- method: "POST",
54856
- headers: { "Content-Type": "application/json", Authorization: token },
54857
- body: JSON.stringify({ query: query2, variables })
54858
- });
55000
+ const out = await linearPostWithRefresh(JSON.stringify({ query: query2, variables }), activeToken, agent, fetchImpl, log, deps.refreshIO);
55001
+ resp = out.resp;
55002
+ activeToken = out.token;
54859
55003
  } catch (err) {
54860
55004
  return { ok: false, text: `request error: ${err.message}` };
54861
55005
  }
@@ -17,8 +17,10 @@
17
17
 
18
18
  import {
19
19
  getViaBrokerStructured,
20
+ putViaBroker,
20
21
  readVaultTokenFile,
21
22
  } from '../../src/vault/broker/client.js'
23
+ import { performLinearRefresh, type RefreshIO } from '../../src/linear/oauth-refresh.js'
22
24
 
23
25
  export const LINEAR_GRAPHQL_ENDPOINT = 'https://api.linear.app/graphql'
24
26
 
@@ -33,6 +35,10 @@ export interface LinearActivityDeps {
33
35
  fetchImpl?: typeof fetch
34
36
  /** Agent slug (defaults to SWITCHROOM_AGENT_NAME). */
35
37
  agent?: string
38
+ /** Build the RefreshIO used to auto-refresh on a 401 (tests inject a
39
+ * fake; production uses the broker-backed `brokerRefreshIO`). When
40
+ * omitted, on-401 refresh uses the broker. */
41
+ refreshIO?: (agent: string) => RefreshIO
36
42
  /** Default Linear team id for captured issues (multi-team workspaces);
37
43
  * defaults to SWITCHROOM_LINEAR_DEFAULT_TEAM_ID. Tests inject directly. */
38
44
  defaultTeamId?: string
@@ -56,6 +62,79 @@ export async function defaultResolveLinearToken(agent: string): Promise<LinearTo
56
62
  return { ok: false, reason: 'unknown' }
57
63
  }
58
64
 
65
+ /**
66
+ * Broker-backed RefreshIO: reads `linear/<agent>/oauth` and rotates both
67
+ * `linear/<agent>/token` and the bundle via the broker `put` op (#950 — an
68
+ * agent rotates keys it can already read, no operator passphrase, no host
69
+ * round-trip). Requires `linear/<agent>/oauth` to be in the agent's ACL
70
+ * (linear-agent setup adds it to `secrets[]`).
71
+ */
72
+ export function brokerRefreshIO(agent: string, fetchImpl?: typeof fetch): RefreshIO {
73
+ const token = readVaultTokenFile(agent) ?? undefined
74
+ const opt = token ? { token } : {}
75
+ return {
76
+ readBundle: async () => {
77
+ const r = await getViaBrokerStructured(`linear/${agent}/oauth`, opt)
78
+ return r.kind === 'ok' && r.entry.kind === 'string' ? r.entry.value : null
79
+ },
80
+ writeToken: async (t) => {
81
+ const r = await putViaBroker(`linear/${agent}/token`, { kind: 'string', value: t }, opt)
82
+ if (r.kind !== 'ok') throw new Error(`broker put linear/${agent}/token: ${r.kind}`)
83
+ },
84
+ writeBundle: async (j) => {
85
+ const r = await putViaBroker(`linear/${agent}/oauth`, { kind: 'string', value: j }, opt)
86
+ if (r.kind !== 'ok') throw new Error(`broker put linear/${agent}/oauth: ${r.kind}`)
87
+ },
88
+ ...(fetchImpl ? { fetchImpl } : {}),
89
+ }
90
+ }
91
+
92
+ /**
93
+ * POST to Linear's GraphQL endpoint; on a 401 (expired/invalid app token)
94
+ * auto-refresh the token once via the stored refresh bundle and retry with
95
+ * the fresh token. This is the durable self-heal: a Linear app token that
96
+ * expired (~24h–30d) is silently rotated in-container on its next use rather
97
+ * than failing the agent's turn. A `revoked` refresh token (the one case
98
+ * needing operator re-auth) is logged loudly and the original 401 surfaces.
99
+ *
100
+ * Returns the final Response and the token in force (callers read the body).
101
+ */
102
+ async function linearPostWithRefresh(
103
+ body: string,
104
+ token: string,
105
+ agent: string,
106
+ fetchImpl: typeof fetch,
107
+ log: (s: string) => void,
108
+ refreshIO?: (agent: string) => RefreshIO,
109
+ ): Promise<{ resp: Response; token: string }> {
110
+ const post = (t: string) =>
111
+ fetchImpl(LINEAR_GRAPHQL_ENDPOINT, {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json', Authorization: t },
114
+ body,
115
+ })
116
+
117
+ let resp = await post(token)
118
+ if (resp.status !== 401) return { resp, token }
119
+
120
+ const io = (refreshIO ?? ((a) => brokerRefreshIO(a, fetchImpl)))(agent)
121
+ const refreshed = await performLinearRefresh({ ...io, fetchImpl })
122
+ if (!refreshed.ok) {
123
+ if (refreshed.reason === 'revoked') {
124
+ log(
125
+ `telegram gateway: linear token REVOKED agent=${agent} — refresh token is dead; ` +
126
+ `operator must re-authorize (linear-agent setup --refresh-token …)\n`,
127
+ )
128
+ } else {
129
+ log(`telegram gateway: linear token refresh failed agent=${agent} reason=${refreshed.reason}\n`)
130
+ }
131
+ return { resp, token } // surface the original 401
132
+ }
133
+ log(`telegram gateway: linear token auto-refreshed agent=${agent} (was 401)\n`)
134
+ resp = await post(refreshed.accessToken)
135
+ return { resp, token: refreshed.accessToken }
136
+ }
137
+
59
138
  /**
60
139
  * Emit a Linear AgentActivity. Validates args, resolves the token, POSTs
61
140
  * the `agentActivityCreate` mutation, and returns an MCP text result. Never
@@ -118,14 +197,16 @@ export async function emitLinearAgentActivity(
118
197
  const fetchImpl = deps.fetchImpl ?? fetch
119
198
  let resp: Response
120
199
  try {
121
- resp = await fetchImpl(LINEAR_GRAPHQL_ENDPOINT, {
122
- method: 'POST',
123
- headers: {
124
- 'Content-Type': 'application/json',
125
- Authorization: tokenResult.token,
126
- },
127
- body: JSON.stringify({ query: mutation, variables }),
128
- })
200
+ // Auto-refresh on a 401 (expired app token) and retry once — see
201
+ // linearPostWithRefresh.
202
+ ;({ resp } = await linearPostWithRefresh(
203
+ JSON.stringify({ query: mutation, variables }),
204
+ tokenResult.token,
205
+ agent,
206
+ fetchImpl,
207
+ log,
208
+ deps.refreshIO,
209
+ ))
129
210
  } catch (err) {
130
211
  return {
131
212
  content: [{ type: 'text', text: `linear_agent_activity failed: request error: ${(err as Error).message}` }],
@@ -217,16 +298,23 @@ export async function createLinearIssue(
217
298
  ],
218
299
  }
219
300
  }
220
- const token = tokenResult.token
301
+ // Mutable so a 401-triggered refresh on one gql call carries the fresh
302
+ // token to subsequent calls in this issue-create flow.
303
+ let activeToken = tokenResult.token
221
304
 
222
305
  const gql = async (query: string, variables: Record<string, unknown>): Promise<{ ok: true; data: any } | { ok: false; text: string }> => {
223
306
  let resp: Response
224
307
  try {
225
- resp = await fetchImpl(LINEAR_GRAPHQL_ENDPOINT, {
226
- method: 'POST',
227
- headers: { 'Content-Type': 'application/json', Authorization: token },
228
- body: JSON.stringify({ query, variables }),
229
- })
308
+ const out = await linearPostWithRefresh(
309
+ JSON.stringify({ query, variables }),
310
+ activeToken,
311
+ agent,
312
+ fetchImpl,
313
+ log,
314
+ deps.refreshIO,
315
+ )
316
+ resp = out.resp
317
+ activeToken = out.token
230
318
  } catch (err) {
231
319
  return { ok: false, text: `request error: ${(err as Error).message}` }
232
320
  }
@@ -122,3 +122,78 @@ describe('emitLinearAgentActivity — behaviour (#2298)', () => {
122
122
  expect(r.content[0].text).toMatch(/Linear API 401/)
123
123
  })
124
124
  })
125
+
126
+ import { serializeBundle, type RefreshIO } from '../../src/linear/oauth-refresh.js'
127
+
128
+ /** fetch fake routing by URL: the GraphQL endpoint 401s on the first hit then
129
+ * 200s; the OAuth token endpoint returns a fresh token. Records the
130
+ * Authorization header per call so we can prove the retry used the new token. */
131
+ function refreshAwareFetch(opts: { tokenStatus?: number; tokenBody?: unknown } = {}) {
132
+ const calls: Array<{ url: string; auth?: string }> = []
133
+ let graphqlHits = 0
134
+ const fetchImpl = (async (url: string, init: { headers?: Record<string, string> }) => {
135
+ const auth = init?.headers?.Authorization
136
+ calls.push({ url, auth })
137
+ if (url.includes('/oauth/token')) {
138
+ const status = opts.tokenStatus ?? 200
139
+ const body = opts.tokenBody ?? { access_token: 'lin_fresh', refresh_token: 'rt_new', expires_in: 86400 }
140
+ return { ok: status >= 200 && status < 300, status, json: async () => body, text: async () => (typeof body === 'string' ? body : JSON.stringify(body)) } as unknown as Response
141
+ }
142
+ graphqlHits++
143
+ if (graphqlHits === 1) {
144
+ return { ok: false, status: 401, json: async () => ({}), text: async () => 'unauthorized' } as unknown as Response
145
+ }
146
+ return { ok: true, status: 200, json: async () => ({ data: { agentActivityCreate: { success: true } } }), text: async () => '' } as unknown as Response
147
+ }) as unknown as typeof fetch
148
+ return { fetchImpl, calls }
149
+ }
150
+
151
+ function fakeRefreshIO(): { io: RefreshIO; writes: { token?: string; bundle?: string } } {
152
+ const writes: { token?: string; bundle?: string } = {}
153
+ const io: RefreshIO = {
154
+ readBundle: async () => serializeBundle({ clientId: 'cid', clientSecret: 'csec', refreshToken: 'rt_old', expiresAt: 0 }),
155
+ writeToken: async (t) => { writes.token = t },
156
+ writeBundle: async (j) => { writes.bundle = j },
157
+ }
158
+ return { io, writes }
159
+ }
160
+
161
+ describe('linear_agent_activity — auto-refresh on 401 (#2298 durability)', () => {
162
+ it('refreshes the app token on a 401 and retries once with the fresh token', async () => {
163
+ const { fetchImpl, calls } = refreshAwareFetch()
164
+ const { io, writes } = fakeRefreshIO()
165
+ const r = await emitLinearAgentActivity(
166
+ { agent_session_id: 'sess', type: 'thought', body: 'hi' },
167
+ { agent: 'carrie', resolveToken: okToken('lin_expired'), fetchImpl, refreshIO: () => io, log: () => {} },
168
+ )
169
+ expect(r.content[0].text).toMatch(/emitted/)
170
+ // The refresh wrote the new access token; the GraphQL retry used it.
171
+ expect(writes.token).toBe('lin_fresh')
172
+ const graphqlAuths = calls.filter((c) => c.url.includes('/graphql')).map((c) => c.auth)
173
+ expect(graphqlAuths).toEqual(['lin_expired', 'lin_fresh'])
174
+ })
175
+
176
+ it('a revoked refresh token surfaces the 401 (one retry, no loop) and logs REVOKED', async () => {
177
+ const { fetchImpl } = refreshAwareFetch({ tokenStatus: 400, tokenBody: 'invalid_grant' })
178
+ const { io } = fakeRefreshIO()
179
+ const logs: string[] = []
180
+ const r = await emitLinearAgentActivity(
181
+ { agent_session_id: 'sess', type: 'thought', body: 'hi' },
182
+ { agent: 'carrie', resolveToken: okToken('lin_dead'), fetchImpl, refreshIO: () => io, log: (s) => logs.push(s) },
183
+ )
184
+ expect(r.content[0].text).toMatch(/Linear API 401/)
185
+ expect(logs.join('')).toMatch(/REVOKED/)
186
+ })
187
+
188
+ it('no 401 → no refresh attempt (happy path unchanged)', async () => {
189
+ const { fetchImpl, calls } = fakeFetch(200, { data: { agentActivityCreate: { success: true } } })
190
+ let refreshBuilt = false
191
+ const r = await emitLinearAgentActivity(
192
+ { agent_session_id: 'sess', type: 'message', body: 'ok' },
193
+ { agent: 'carrie', resolveToken: okToken('lin_good'), fetchImpl, refreshIO: () => { refreshBuilt = true; return fakeRefreshIO().io }, log: () => {} },
194
+ )
195
+ expect(r.content[0].text).toMatch(/emitted/)
196
+ expect(refreshBuilt).toBe(false)
197
+ expect(calls.length).toBe(1)
198
+ })
199
+ })
@@ -209,3 +209,45 @@ describe('createLinearIssue — behaviour (#2312)', () => {
209
209
  expect(r.content[0].text).toMatch(/Linear API 401/)
210
210
  })
211
211
  })
212
+
213
+ import { serializeBundle, type RefreshIO } from '../../src/linear/oauth-refresh.js'
214
+
215
+ describe('createLinearIssue — token threading across gql calls after a 401 refresh', () => {
216
+ it('a 401 on the teams resolve refreshes, and issueCreate carries the FRESH token', async () => {
217
+ // No team_id + no dedup → flow is: teams(query) then issueCreate(mutation).
218
+ // The teams call 401s → refresh → retry; activeToken must then carry the
219
+ // fresh token to issueCreate. Pins the mutable-activeToken threading.
220
+ const auths: Array<{ op: string; auth?: string }> = []
221
+ let teamsHits = 0
222
+ const fetchImpl = (async (url: string, init: { headers?: Record<string, string>; body?: string }) => {
223
+ const auth = init?.headers?.Authorization
224
+ const body = init?.body ?? ''
225
+ if (url.includes('/oauth/token')) {
226
+ return { ok: true, status: 200, json: async () => ({ access_token: 'lin_fresh', expires_in: 3600 }), text: async () => '' } as unknown as Response
227
+ }
228
+ if (body.includes('teams(')) {
229
+ auths.push({ op: 'teams', auth })
230
+ teamsHits++
231
+ if (teamsHits === 1) return { ok: false, status: 401, json: async () => ({}), text: async () => 'unauthorized' } as unknown as Response
232
+ return { ok: true, status: 200, json: async () => ({ data: { teams: { nodes: [{ id: 'team_1', key: 'ENG', name: 'Eng' }] } } }), text: async () => '' } as unknown as Response
233
+ }
234
+ // issueCreate
235
+ auths.push({ op: 'issueCreate', auth })
236
+ return { ok: true, status: 200, json: async () => ({ data: { issueCreate: { success: true, issue: { identifier: 'ENG-1', url: 'https://linear.app/x/issue/ENG-1' } } } }), text: async () => '' } as unknown as Response
237
+ }) as unknown as typeof fetch
238
+
239
+ const io: RefreshIO = {
240
+ readBundle: async () => serializeBundle({ clientId: 'c', clientSecret: 's', refreshToken: 'rt', expiresAt: 0 }),
241
+ writeToken: async () => {},
242
+ writeBundle: async () => {},
243
+ }
244
+ const r = await createLinearIssue(
245
+ { title: 'a bug' },
246
+ { agent: 'carrie', resolveToken: okToken('lin_expired'), fetchImpl, refreshIO: () => io, log: () => {} },
247
+ )
248
+ expect(r.content[0].text).toMatch(/Filed:/)
249
+ // teams: expired then fresh (retry); issueCreate: fresh (threaded).
250
+ expect(auths.find((a) => a.op === 'issueCreate')?.auth).toBe('lin_fresh')
251
+ expect(auths.filter((a) => a.op === 'teams').map((a) => a.auth)).toEqual(['lin_expired', 'lin_fresh'])
252
+ })
253
+ })