switchroom 0.15.24 → 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.
- package/dist/cli/switchroom.js +203 -8
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +166 -18
- package/telegram-plugin/gateway/linear-activity.ts +102 -14
- package/telegram-plugin/scoped-approval.ts +22 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +75 -0
- package/telegram-plugin/tests/linear-create-issue.test.ts +42 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +33 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -50477,8 +50477,8 @@ var {
|
|
|
50477
50477
|
} = import__.default;
|
|
50478
50478
|
|
|
50479
50479
|
// src/build-info.ts
|
|
50480
|
-
var VERSION = "0.15.
|
|
50481
|
-
var COMMIT_SHA = "
|
|
50480
|
+
var VERSION = "0.15.26";
|
|
50481
|
+
var COMMIT_SHA = "497e0d23";
|
|
50482
50482
|
|
|
50483
50483
|
// src/cli/agent.ts
|
|
50484
50484
|
init_source();
|
|
@@ -52885,7 +52885,7 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
|
|
|
52885
52885
|
const tools = agentConfig.tools ?? { allow: [], deny: [] };
|
|
52886
52886
|
const rawAllow = tools.allow ?? [];
|
|
52887
52887
|
const hasAllWildcard = rawAllow.includes("all");
|
|
52888
|
-
const baseAllow = hasAllWildcard ? ALL_BUILTIN_TOOLS : rawAllow.filter((t) => t !== "all");
|
|
52888
|
+
const baseAllow = hasAllWildcard ? [...ALL_BUILTIN_TOOLS, ...rawAllow.filter((t) => t !== "all")] : rawAllow.filter((t) => t !== "all");
|
|
52889
52889
|
const dangerousMode = agentConfig.dangerous_mode === true;
|
|
52890
52890
|
const hadExplicitAllow = rawAllow.length > 0;
|
|
52891
52891
|
const readOnlyDefaults = !dangerousMode && !hadExplicitAllow ? DEFAULT_READ_ONLY_PREAPPROVED_TOOLS : [];
|
|
@@ -53719,7 +53719,7 @@ function reconcileAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchr
|
|
|
53719
53719
|
const tools = agentConfig.tools ?? { allow: [], deny: [] };
|
|
53720
53720
|
const rawAllow = tools.allow ?? [];
|
|
53721
53721
|
const hasAllWildcard = rawAllow.includes("all");
|
|
53722
|
-
const baseAllow = hasAllWildcard ? ALL_BUILTIN_TOOLS : rawAllow.filter((t) => t !== "all");
|
|
53722
|
+
const baseAllow = hasAllWildcard ? [...ALL_BUILTIN_TOOLS, ...rawAllow.filter((t) => t !== "all")] : rawAllow.filter((t) => t !== "all");
|
|
53723
53723
|
const reconcileDangerousMode = agentConfig.dangerous_mode === true;
|
|
53724
53724
|
const reconcileHadExplicitAllow = rawAllow.length > 0;
|
|
53725
53725
|
const reconcileReadOnlyDefaults = !reconcileDangerousMode && !reconcileHadExplicitAllow ? DEFAULT_READ_ONLY_PREAPPROVED_TOOLS : [];
|
|
@@ -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 (
|
|
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}').`);
|
|
@@ -82565,6 +82759,7 @@ Applying switchroom config...
|
|
|
82565
82759
|
process.exit(6);
|
|
82566
82760
|
}
|
|
82567
82761
|
const composePath = options.outPath ?? DEFAULT_COMPOSE_PATH2;
|
|
82762
|
+
const displayComposePath = toHostHomePath(composePath);
|
|
82568
82763
|
const operatorUid = resolveOperatorUid();
|
|
82569
82764
|
const { bytes: composeBytes } = await writeComposeFile({
|
|
82570
82765
|
config,
|
|
@@ -82575,11 +82770,11 @@ Applying switchroom config...
|
|
|
82575
82770
|
buildContext: options.buildContext
|
|
82576
82771
|
});
|
|
82577
82772
|
writeOut(source_default.bold(`
|
|
82578
|
-
Wrote `) +
|
|
82773
|
+
Wrote `) + displayComposePath + source_default.gray(` (${composeBytes} bytes)
|
|
82579
82774
|
`));
|
|
82580
82775
|
writeOut(`Bring the fleet up with:
|
|
82581
|
-
` + ` docker compose -p ${COMPOSE_PROJECT2} -f ${
|
|
82582
|
-
` + ` docker compose -p ${COMPOSE_PROJECT2} -f ${
|
|
82776
|
+
` + ` docker compose -p ${COMPOSE_PROJECT2} -f ${displayComposePath} pull && \\
|
|
82777
|
+
` + ` docker compose -p ${COMPOSE_PROJECT2} -f ${displayComposePath} up -d --remove-orphans
|
|
82583
82778
|
`);
|
|
82584
82779
|
writeOut(source_default.gray(` (If pull returns 401, login to ghcr.io first: see docs/operators/install.md#ghcr-auth)
|
|
82585
82780
|
`));
|
package/package.json
CHANGED
|
@@ -53639,7 +53639,11 @@ function scopedApprovalTtlMs(env = process.env) {
|
|
|
53639
53639
|
}
|
|
53640
53640
|
var FILE_RULE = /^(Edit|Write|MultiEdit|NotebookEdit|Read)\((.+)\)$/;
|
|
53641
53641
|
var BASH_FAMILY_RULE = /^Bash\(([^:]+):\*\)$/;
|
|
53642
|
+
var READ_ONLY_WHOLE_TOOLS = new Set(["Grep", "Glob"]);
|
|
53642
53643
|
function resolveTimeBox(toolName, inputPreview, choices) {
|
|
53644
|
+
if (READ_ONLY_WHOLE_TOOLS.has(toolName) && choices?.broad) {
|
|
53645
|
+
return { rule: choices.broad.rule, breadth: `any ${toolName}` };
|
|
53646
|
+
}
|
|
53643
53647
|
const specific = choices?.specific;
|
|
53644
53648
|
if (!specific || specific.broad)
|
|
53645
53649
|
return null;
|
|
@@ -54420,10 +54424,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
|
54420
54424
|
}
|
|
54421
54425
|
|
|
54422
54426
|
// ../src/build-info.ts
|
|
54423
|
-
var VERSION = "0.15.
|
|
54424
|
-
var COMMIT_SHA = "
|
|
54425
|
-
var COMMIT_DATE = "2026-06-
|
|
54426
|
-
var LATEST_PR =
|
|
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;
|
|
54427
54431
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
54428
54432
|
|
|
54429
54433
|
// gateway/boot-version.ts
|
|
@@ -54716,6 +54720,112 @@ async function revokeGrantViaBroker(id, opts) {
|
|
|
54716
54720
|
|
|
54717
54721
|
// gateway/linear-activity.ts
|
|
54718
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
|
|
54719
54829
|
var LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql";
|
|
54720
54830
|
async function defaultResolveLinearToken(agent) {
|
|
54721
54831
|
const key = `linear/${agent}/token`;
|
|
@@ -54732,6 +54842,53 @@ async function defaultResolveLinearToken(agent) {
|
|
|
54732
54842
|
return { ok: false, reason: "denied" };
|
|
54733
54843
|
return { ok: false, reason: "unknown" };
|
|
54734
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
|
+
}
|
|
54735
54892
|
async function emitLinearAgentActivity(args, deps = {}) {
|
|
54736
54893
|
const log = deps.log ?? ((s) => process.stderr.write(s));
|
|
54737
54894
|
const sessionId = args.agent_session_id;
|
|
@@ -54776,14 +54933,7 @@ async function emitLinearAgentActivity(args, deps = {}) {
|
|
|
54776
54933
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
54777
54934
|
let resp;
|
|
54778
54935
|
try {
|
|
54779
|
-
resp = await
|
|
54780
|
-
method: "POST",
|
|
54781
|
-
headers: {
|
|
54782
|
-
"Content-Type": "application/json",
|
|
54783
|
-
Authorization: tokenResult.token
|
|
54784
|
-
},
|
|
54785
|
-
body: JSON.stringify({ query: mutation, variables })
|
|
54786
|
-
});
|
|
54936
|
+
({ resp } = await linearPostWithRefresh(JSON.stringify({ query: mutation, variables }), tokenResult.token, agent, fetchImpl, log, deps.refreshIO));
|
|
54787
54937
|
} catch (err) {
|
|
54788
54938
|
return {
|
|
54789
54939
|
content: [{ type: "text", text: `linear_agent_activity failed: request error: ${err.message}` }]
|
|
@@ -54843,15 +54993,13 @@ async function createLinearIssue(args, deps = {}) {
|
|
|
54843
54993
|
]
|
|
54844
54994
|
};
|
|
54845
54995
|
}
|
|
54846
|
-
|
|
54996
|
+
let activeToken = tokenResult.token;
|
|
54847
54997
|
const gql = async (query2, variables) => {
|
|
54848
54998
|
let resp;
|
|
54849
54999
|
try {
|
|
54850
|
-
|
|
54851
|
-
|
|
54852
|
-
|
|
54853
|
-
body: JSON.stringify({ query: query2, variables })
|
|
54854
|
-
});
|
|
55000
|
+
const out = await linearPostWithRefresh(JSON.stringify({ query: query2, variables }), activeToken, agent, fetchImpl, log, deps.refreshIO);
|
|
55001
|
+
resp = out.resp;
|
|
55002
|
+
activeToken = out.token;
|
|
54855
55003
|
} catch (err) {
|
|
54856
55004
|
return { ok: false, text: `request error: ${err.message}` };
|
|
54857
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
}
|
|
@@ -83,6 +83,16 @@ export interface TimeBoxDecision {
|
|
|
83
83
|
const FILE_RULE = /^(Edit|Write|MultiEdit|NotebookEdit|Read)\((.+)\)$/;
|
|
84
84
|
const BASH_FAMILY_RULE = /^Bash\(([^:]+):\*\)$/;
|
|
85
85
|
|
|
86
|
+
// Read-only whole-tool inspection tools. permission-rule.ts classifies these
|
|
87
|
+
// as BROAD_ONLY (no meaningful path sub-scope — Grep/Glob match a pattern
|
|
88
|
+
// across files), so they only ever offer a broad "any Grep/Glob" grant. They
|
|
89
|
+
// are READ-ONLY — they cannot mutate anything — so time-boxing that broad grant
|
|
90
|
+
// is low-risk and is exactly the dominant "stop re-asking for the same
|
|
91
|
+
// low-risk inspection" case (e.g. grepping a file with several regexes). This
|
|
92
|
+
// deliberately EXCLUDES WebFetch/WebSearch (also BROAD_ONLY): network egress is
|
|
93
|
+
// a different risk class, and webkite denies them anyway.
|
|
94
|
+
const READ_ONLY_WHOLE_TOOLS = new Set(["Grep", "Glob"]);
|
|
95
|
+
|
|
86
96
|
/**
|
|
87
97
|
* Conservative time-box eligibility. Given the already-resolved scope
|
|
88
98
|
* choices for a permission request, return the NARROW rule to time-box
|
|
@@ -96,12 +106,24 @@ const BASH_FAMILY_RULE = /^Bash\(([^:]+):\*\)$/;
|
|
|
96
106
|
* triggering command itself is non-destructive. The family grant
|
|
97
107
|
* still covers the whole `<tok>` family for matching, but match-time
|
|
98
108
|
* re-checks each later command (see `lookupScopedGrant`).
|
|
109
|
+
* - Read-only whole-tool inspection (Grep/Glob) → time-boxable on the
|
|
110
|
+
* broad grant: no sub-scope exists, but they cannot mutate anything, so
|
|
111
|
+
* "any Grep for 30 min" is the safe, dominant low-risk-repeat case.
|
|
99
112
|
*/
|
|
100
113
|
export function resolveTimeBox(
|
|
101
114
|
toolName: string,
|
|
102
115
|
inputPreview: string | undefined,
|
|
103
116
|
choices: ScopedAllowChoices | null,
|
|
104
117
|
): TimeBoxDecision | null {
|
|
118
|
+
// Read-only whole-tool inspection (Grep/Glob) has no narrow sub-scope, only
|
|
119
|
+
// a broad grant — but it is read-only, so the broad grant is safe to
|
|
120
|
+
// time-box and is the dominant low-risk-repeat case. A stored bare-tool
|
|
121
|
+
// rule ("Grep") matches every later Grep call (matchesAllowRule: rule ===
|
|
122
|
+
// toolName), so different regexes/paths all dedup for the window.
|
|
123
|
+
if (READ_ONLY_WHOLE_TOOLS.has(toolName) && choices?.broad) {
|
|
124
|
+
return { rule: choices.broad.rule, breadth: `any ${toolName}` };
|
|
125
|
+
}
|
|
126
|
+
|
|
105
127
|
// Only ever time-box the narrow scope, and only when one exists.
|
|
106
128
|
const specific = choices?.specific;
|
|
107
129
|
if (!specific || specific.broad) return null;
|
|
@@ -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
|
+
})
|
|
@@ -100,6 +100,26 @@ describe('resolveTimeBox — conservative eligibility', () => {
|
|
|
100
100
|
expect(timeBoxRule('Skill', JSON.stringify({ skill: 'deep-research' }))).toBeNull()
|
|
101
101
|
expect(timeBoxRule('TotallyUnknown', '{}')).toBeNull()
|
|
102
102
|
})
|
|
103
|
+
|
|
104
|
+
it('time-boxes read-only whole-tool inspection (Grep/Glob) on the broad grant', () => {
|
|
105
|
+
// No narrow sub-scope exists (BROAD_ONLY), but they are read-only — so the
|
|
106
|
+
// broad grant is safe to time-box. This is the dominant "stop re-asking for
|
|
107
|
+
// the same low-risk inspection" case the operator wants.
|
|
108
|
+
expect(timeBoxRule('Grep', JSON.stringify({ pattern: 'foo', path: '/state/x.ts' }))).toBe('Grep')
|
|
109
|
+
expect(timeBoxRule('Glob', JSON.stringify({ pattern: '**/*.ts' }))).toBe('Glob')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('honest breadth for the read-only whole-tool window', () => {
|
|
113
|
+
const choices = resolveScopedAllowChoices('Grep', JSON.stringify({ pattern: 'foo' }))
|
|
114
|
+
expect(resolveTimeBox('Grep', JSON.stringify({ pattern: 'foo' }), choices)?.breadth).toBe('any Grep')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('still does NOT time-box network-egress broad-only tools (WebFetch/WebSearch)', () => {
|
|
118
|
+
// Read-only-of-the-filesystem is the bar; network egress is a different
|
|
119
|
+
// risk class and is excluded on purpose (also denied via webkite).
|
|
120
|
+
expect(timeBoxRule('WebFetch', JSON.stringify({ url: 'https://x' }))).toBeNull()
|
|
121
|
+
expect(timeBoxRule('WebSearch', JSON.stringify({ query: 'x' }))).toBeNull()
|
|
122
|
+
})
|
|
103
123
|
})
|
|
104
124
|
|
|
105
125
|
describe('lookupScopedGrant — no seed, no extend, fail closed', () => {
|
|
@@ -121,6 +141,19 @@ describe('lookupScopedGrant — no seed, no extend, fail closed', () => {
|
|
|
121
141
|
expect(lookupScopedGrant(store, 'clerk', 'Edit', editInput('/state/y.ts'), T0 + 1)).toBeNull()
|
|
122
142
|
})
|
|
123
143
|
|
|
144
|
+
it('a Grep grant dedups later Greps with DIFFERENT regexes (the spam case)', () => {
|
|
145
|
+
// Operator taps ✅ Allow on the first Grep → whole-tool window. The agent
|
|
146
|
+
// then greps the same file with 2 more regexes — both auto-allow, no
|
|
147
|
+
// re-prompt, for the life of the window.
|
|
148
|
+
const store: ScopedGrantStore = new Map()
|
|
149
|
+
const t = resolveTimeBox('Grep', JSON.stringify({ pattern: 'foo' }), resolveScopedAllowChoices('Grep', JSON.stringify({ pattern: 'foo' })))
|
|
150
|
+
recordScopedGrant(store, 'clerk', t!.rule, T0, TTL)
|
|
151
|
+
expect(lookupScopedGrant(store, 'clerk', 'Grep', JSON.stringify({ pattern: 'bar' }), T0 + 1_000)).toBe('Grep')
|
|
152
|
+
expect(lookupScopedGrant(store, 'clerk', 'Grep', JSON.stringify({ pattern: 'baz', path: '/state/other.ts' }), T0 + 2_000)).toBe('Grep')
|
|
153
|
+
// …and it still fails closed after the window.
|
|
154
|
+
expect(lookupScopedGrant(store, 'clerk', 'Grep', JSON.stringify({ pattern: 'bar' }), T0 + TTL)).toBeNull()
|
|
155
|
+
})
|
|
156
|
+
|
|
124
157
|
it('FIXED window — a matching call never extends expiresAt', () => {
|
|
125
158
|
const store: ScopedGrantStore = new Map()
|
|
126
159
|
recordScopedGrant(store, 'clerk', 'Edit(/state/x.ts)', T0, TTL)
|