@sentry/junior 0.263.0 → 0.265.0
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/README.md +1 -1
- package/dist/{adapter-BIacI0Bt.js → adapter-cUkPH3Om.js} +1 -1
- package/dist/{admin-C7vUk7s9.js → admin-Cf0rDmWV.js} +1 -1
- package/dist/{agent-runner-BUBas7Yy.js → agent-runner-C6MwHJEs.js} +296 -238
- package/dist/api/schema.d.ts +213 -16
- package/dist/api/schema.js +3 -3
- package/dist/api.js +107 -40
- package/dist/{app-Bo0kB987.d.ts → app-Csbi9dW7.d.ts} +4 -3
- package/dist/app.d.ts +1 -1
- package/dist/app.js +101 -393
- package/dist/{assistant-reply-BFmjgJjD.js → assistant-reply-UPA8vKlC.js} +1 -1
- package/dist/{automation-CWX0qL8U.js → automation-2RbKzpMT.js} +30 -9
- package/dist/{briefs-h55v5lX9.js → briefs-DRjx8kQ9.js} +3 -3
- package/dist/{catalog-runtime-Suf-aLlh.js → catalog-runtime-D3qbJlYz.js} +1 -1
- package/dist/cli/chat.js +15 -15
- package/dist/cli/check.js +3 -3
- package/dist/cli/init.js +4 -18
- package/dist/cli/main.js +4 -4
- package/dist/cli/plugins.js +4 -4
- package/dist/cli/snapshot-create.js +4 -4
- package/dist/cli/upgrade.js +3 -3
- package/dist/{client-DZBCBj1M.js → client-DX34VNtH.js} +3 -8
- package/dist/{config-Dnv8KmNs.js → config-BayQ9hHH.js} +100 -3
- package/dist/{connections-Br0_xv27.js → connections-mpN5Lsnj.js} +11 -10
- package/dist/{conversation-privacy-FY8yc2q3.js → conversation-privacy-frjQCJa9.js} +1 -1
- package/dist/{credential-sync-BvkiyWF5.js → credential-sync-CZmyDNdR.js} +20 -20
- package/dist/{agent-hooks-CqnZfuCt.js → credentialed-C6pKlB1p.js} +3197 -66
- package/dist/{executor-J9k2sp9m.js → executor-CAr7xQr2.js} +10 -300
- package/dist/{images-DV2FlEGL.js → images-B2yA3HWE.js} +2 -2
- package/dist/instrumentation.js +41 -25
- package/dist/{job-queue-B4yTgklN.js → job-queue-Cjo6Yg_b.js} +53 -14
- package/dist/{logging-Dk_FpF36.js → logging-D2QBriUo.js} +19 -32
- package/dist/{message-BzseAFH1.js → message-C9ymnpBS.js} +4 -4
- package/dist/nitro.d.ts +17 -2
- package/dist/nitro.js +26 -27
- package/dist/{oauth-callback-server-bTspTMCC.js → oauth-callback-server-BtMf3TPZ.js} +2 -2
- package/dist/{prompt-CxatQVaj.js → prompt-CvaDdSX3.js} +1 -1
- package/dist/{registry-BFzQFV_y.js → registry-COTeZ2cV.js} +1 -1
- package/dist/{resolve-CnRHib0U.js → resolve-g9PKz9pH.js} +7 -106
- package/dist/{result-DczfmNBa.js → result-COnC42GR.js} +8 -8
- package/dist/{runner-CJd9Wec-.js → runner-BnTNS7x_.js} +10 -10
- package/dist/{sandbox-egress-signals-DUmVkOx4.js → sandbox-egress-signals-C_IIdLFk.js} +1 -1
- package/dist/{schema-QidbykYl.js → schema-D5Amlzkp.js} +44 -3
- package/dist/{sentry-BBUlHih9.js → sentry-MDkYAVPV.js} +3 -6
- package/dist/{service-account-BI2kQDrb.js → service-account-CwvToidT.js} +3 -3
- package/dist/{service-account-x2QZXm4X.js → service-account-YdYftwV2.js} +5 -5
- package/dist/{session-stop-CtyJsnEj.js → session-stop-CpCUy1ZJ.js} +121 -81
- package/dist/{skills-Dbzx9-rS.js → skills-Bb2KaJ3m.js} +2 -2
- package/dist/{spawn-D8WU99UV.js → spawn-mbTcrLb3.js} +2 -2
- package/dist/{store-TKg1HVKx.js → store-CqRnaZSq.js} +3 -3
- package/dist/{store-UlOrW6JC.js → store-DdT74Lvq.js} +18 -9
- package/dist/{task-queue-BRB49H-J.js → task-queue-8XjDIORF.js} +14 -19
- package/dist/{task-runner-C575f2Dd.js → task-runner-6mLmG6g_.js} +46 -28
- package/dist/{tasks-LOItiYgL.js → tasks-DGbrur6x.js} +14 -2075
- package/dist/{thread-state-IDHdZA7R.js → thread-state-D45lz-oC.js} +5 -5
- package/dist/{turn-failure-response-h_LSXJ-M.js → turn-failure-response-CaEPLSBn.js} +7 -7
- package/dist/{turn-lifecycle-Cvi6Sb8Y.js → turn-lifecycle-C35IyRGp.js} +1 -1
- package/dist/{turn-session-routing-5Nyv4fso.js → turn-session-routing-BKx6rfVd.js} +1 -1
- package/dist/{validation-BeTriWC2.js → validation-C0X3CX51.js} +1 -1
- package/dist/{version-BQX_hLDC.js → version-BPHgJwnm.js} +1 -1
- package/dist/version.js +1 -1
- package/dist/{viewer-DmNRM88V.js → viewer-B2wXNspU.js} +21 -5
- package/dist/{web-input-BHcGXSox.js → web-input-Y6aXvmfa.js} +145 -46
- package/dist/{work-DLJMV02b.js → work-DMbqK0kT.js} +10 -10
- package/dist/{worker-vfG2ESg1.js → worker-B-rDJ3XN.js} +8 -6
- package/package.json +10 -9
- package/dist/profile-Dq5I7TV1.js +0 -582
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-nwUcYXjP.js";
|
|
2
|
-
import { i as completeObject, o as embedTexts } from "./client-
|
|
3
|
-
import {
|
|
4
|
-
import { a as
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
2
|
+
import { C as toOptionalTrimmed, i as completeObject, o as embedTexts } from "./client-DX34VNtH.js";
|
|
3
|
+
import { i as getChatConfig, n as botConfig, o as getSlackBotToken, v as normalizeSlackEmojiName } from "./config-BayQ9hHH.js";
|
|
4
|
+
import { T as withSpan, a as extractGenAiUsageSummary, f as logException, m as logWarn, p as logInfo, s as getLogContextAttributes, u as logAudit, v as setSentryUser, y as setSpanAttributes, z as isRecord$2 } from "./logging-D2QBriUo.js";
|
|
5
|
+
import { a as getClient$1, n as captureMessage, r as continueTrace } from "./sentry-MDkYAVPV.js";
|
|
6
|
+
import { t as canExposeConversationPayload } from "./conversation-privacy-frjQCJa9.js";
|
|
7
7
|
import { r as defaultModelId } from "./model-profile-Bg8EoCKD.js";
|
|
8
|
-
import { d as parseSlackTeamId, g as parseSlackMessageTs, i as isActorUserId, o as parseActorUserId, u as parseSlackChannelReferenceId } from "./actor-tlxzqaRe.js";
|
|
9
|
-
import {
|
|
8
|
+
import { d as parseSlackTeamId, f as parseSlackUserId, g as parseSlackMessageTs, i as isActorUserId, o as parseActorUserId, u as parseSlackChannelReferenceId } from "./actor-tlxzqaRe.js";
|
|
9
|
+
import { F as saveUserTimezone, N as readUserTimezone, O as hasAgentTurnUsage, _ as createSqlStore, a as resolveViewerUser, f as getDb, i as readIdentityById, l as getConversationEventStore, p as getSqlExecutor, r as readActorIdentity, z as parseDestination } from "./viewer-B2wXNspU.js";
|
|
10
10
|
import { c as juniorIdentities, d as juniorDestinations, l as juniorUsers, s as juniorConversations } from "./agent-invocations-CxKldAeB.js";
|
|
11
|
-
import { _ as juniorConversationAnnotations, b as juniorAttachments, f as juniorConversationBriefs, h as juniorConversationEvents, i as juniorWorkspaces, n as juniorSnapshots, r as juniorWorkspaceRepos, u as juniorConversationParticipants, v as juniorCodeChanges, y as juniorCodeRepositories } from "./executor-
|
|
11
|
+
import { _ as juniorConversationAnnotations, b as juniorAttachments, f as juniorConversationBriefs, h as juniorConversationEvents, i as juniorWorkspaces, n as juniorSnapshots, r as juniorWorkspaceRepos, u as juniorConversationParticipants, v as juniorCodeChanges, y as juniorCodeRepositories } from "./executor-CAr7xQr2.js";
|
|
12
12
|
import { o as decodeStoredConversationEvent } from "./history-AY8rs-FR.js";
|
|
13
|
-
import { h as credentialSubjectSchema } from "./registry-
|
|
14
|
-
import { n as
|
|
13
|
+
import { _ as CredentialUnavailableError, a as resolveAuthTokenPlaceholder, h as credentialSubjectSchema, m as credentialLogContext, o as resolvePluginCommandEnv, p as credentialContextSchema } from "./registry-COTeZ2cV.js";
|
|
14
|
+
import { n as pluginCatalogRuntime } from "./catalog-runtime-D3qbJlYz.js";
|
|
15
|
+
import { a as acquireActiveLock, n as getDefaultRedisStateAdapterFor, r as getStateAdapter, s as withLock } from "./adapter-cUkPH3Om.js";
|
|
15
16
|
import { i as isValidTimeZone } from "./cadence-R7Em4RjE.js";
|
|
16
17
|
import { B as readMessageAttachments, C as conversationReportEventSchema, F as readMessageCardRefs, J as parseBriefInput, q as conversationBriefSchema, w as conversationReportMessageEventSchema } from "./conversation-B0baEwoA.js";
|
|
17
|
-
import { n as briefEntriesFromReportEvents, s as generateBrief, t as BRIEF_PROMPT } from "./prompt-
|
|
18
|
-
import { D as SANDBOX_WORKSPACE_ROOT, S as runNonInteractiveCommand, l as isSandboxMissingError, n as hash, v as getVercelSandboxCredentials, y as sleep$1 } from "./profile-Dq5I7TV1.js";
|
|
18
|
+
import { n as briefEntriesFromReportEvents, s as generateBrief, t as BRIEF_PROMPT } from "./prompt-CvaDdSX3.js";
|
|
19
19
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
20
|
import { z } from "zod";
|
|
21
|
-
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
21
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
22
22
|
import { and, arrayContains, asc, desc, eq, exists, getTableColumns, gte, inArray, isNotNull, isNull, lt, lte, max, ne, notExists, or, sql } from "drizzle-orm";
|
|
23
|
-
import { codeChangeInputSchema, conversationAnnotationInputSchema, conversationEventPresentationSchema, defineConversationEvent, eventInputSchema, eventMatchSchema, eventMatches, eventTypeSchema, missingToolAnnotationKeys, normalizeEventIdentifier, objectAnnotationSchema, objectIconPath, objectTypeSchema, pluginEventsSchema, promptContextSchema, promptMessageSchema, stableEventMatchKey } from "@sentry/junior-plugin-api";
|
|
23
|
+
import { EgressAuthRequired, EgressPolicyDenied, actorSchema, codeChangeInputSchema, conversationAnnotationInputSchema, conversationEventPresentationSchema, defineConversationEvent, eventInputSchema, eventMatchSchema, eventMatches, eventTypeSchema, missingToolAnnotationKeys, normalizeEventIdentifier, objectAnnotationSchema, objectIconPath, objectTypeSchema, pluginAuthorizationSchema, pluginCredentialHeaderTransformSchema, pluginCredentialResultSchema, pluginEventsSchema, pluginGrantSchema, pluginProviderAccountSchema, pluginStoredTokensSchema, promptContextSchema, promptMessageSchema, sourceSchema, stableEventMatchKey } from "@sentry/junior-plugin-api";
|
|
24
24
|
import { alias, union } from "drizzle-orm/pg-core";
|
|
25
25
|
import { WebClient } from "@slack/web-api";
|
|
26
|
-
import { Sandbox } from "@vercel/sandbox";
|
|
26
|
+
import { FileSystem, Sandbox } from "@vercel/sandbox";
|
|
27
|
+
import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
|
|
27
28
|
// src/chat/conversations/structured-events.ts
|
|
28
29
|
/** Reserved namespace for Junior-owned conversation metadata events. */
|
|
29
30
|
const JUNIOR_NATIVE_EVENT_NAMESPACE = "junior";
|
|
@@ -394,8 +395,8 @@ async function saveObjectAnnotations(conversationId, plugin, value) {
|
|
|
394
395
|
}
|
|
395
396
|
/** Save annotations from a successful plugin tool result and attach their cards. */
|
|
396
397
|
async function annotateToolResult(conversationId, plugin, result) {
|
|
397
|
-
if (!isRecord$
|
|
398
|
-
const details = isRecord$
|
|
398
|
+
if (!isRecord$2(result) || result.isError === true) return result;
|
|
399
|
+
const details = isRecord$2(result.details) ? result.details : result;
|
|
399
400
|
if (details.timed_out === true || details.isError === true || !Array.isArray(details.objectAnnotations)) return result;
|
|
400
401
|
const cards = await saveObjectAnnotations(conversationId, plugin, details.objectAnnotations);
|
|
401
402
|
const { objectAnnotations: _annotations, ...rest } = details;
|
|
@@ -403,7 +404,7 @@ async function annotateToolResult(conversationId, plugin, result) {
|
|
|
403
404
|
...rest,
|
|
404
405
|
objectCards: cards
|
|
405
406
|
};
|
|
406
|
-
if (isRecord$
|
|
407
|
+
if (isRecord$2(result.details)) return {
|
|
407
408
|
...result,
|
|
408
409
|
details: annotated
|
|
409
410
|
};
|
|
@@ -1832,6 +1833,100 @@ function createPluginState(plugin, adapter) {
|
|
|
1832
1833
|
}
|
|
1833
1834
|
};
|
|
1834
1835
|
}
|
|
1836
|
+
// src/chat/sandbox/paths.ts
|
|
1837
|
+
function normalizeWorkspaceRoot(input) {
|
|
1838
|
+
const candidate = (input ?? "").trim();
|
|
1839
|
+
if (!candidate) return "/vercel/sandbox";
|
|
1840
|
+
const normalized = candidate.replace(/\/+$/, "");
|
|
1841
|
+
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
1842
|
+
}
|
|
1843
|
+
const SANDBOX_WORKSPACE_ROOT = normalizeWorkspaceRoot(process.env.VERCEL_SANDBOX_WORKSPACE_DIR);
|
|
1844
|
+
const SANDBOX_REPOS_ROOT = `${SANDBOX_WORKSPACE_ROOT}/repos`;
|
|
1845
|
+
const SANDBOX_SKILLS_ROOT = `${SANDBOX_WORKSPACE_ROOT}/skills`;
|
|
1846
|
+
const SANDBOX_DATA_ROOT = `${SANDBOX_WORKSPACE_ROOT}/data`;
|
|
1847
|
+
/**
|
|
1848
|
+
* PATH for sandbox commands. Plugin runtime binaries come last so they never
|
|
1849
|
+
* shadow repo or Workspace tools with the same name. Repo tools often start
|
|
1850
|
+
* child processes through PATH, so only PATH order can protect them. Plugin
|
|
1851
|
+
* binaries stay reachable by name or by absolute path.
|
|
1852
|
+
*/
|
|
1853
|
+
const SANDBOX_COMMAND_PATH = `$PATH:${`${SANDBOX_WORKSPACE_ROOT}/.junior/bin`}`;
|
|
1854
|
+
function sandboxSkillDir(skillName) {
|
|
1855
|
+
return `${SANDBOX_SKILLS_ROOT}/${skillName}`;
|
|
1856
|
+
}
|
|
1857
|
+
function sandboxSkillFile(skillName) {
|
|
1858
|
+
return `${sandboxSkillDir(skillName)}/SKILL.md`;
|
|
1859
|
+
}
|
|
1860
|
+
/** Explain how the agent should resolve files and commands owned by a skill. */
|
|
1861
|
+
function sandboxSkillPathResolution(skillName) {
|
|
1862
|
+
const skillDir = sandboxSkillDir(skillName);
|
|
1863
|
+
return `Resolve relative paths in this skill against ${skillDir}. For bash commands from this skill, cd to ${skillDir} first or use absolute paths.`;
|
|
1864
|
+
}
|
|
1865
|
+
// src/chat/sandbox/noninteractive-command.ts
|
|
1866
|
+
const NON_INTERACTIVE_ENV = {
|
|
1867
|
+
CI: "1",
|
|
1868
|
+
TERM: "dumb",
|
|
1869
|
+
NO_COLOR: "1",
|
|
1870
|
+
PAGER: "cat",
|
|
1871
|
+
GIT_PAGER: "cat",
|
|
1872
|
+
GH_PROMPT_DISABLED: "1",
|
|
1873
|
+
GH_NO_UPDATE_NOTIFIER: "1",
|
|
1874
|
+
GH_NO_EXTENSION_UPDATE_NOTIFIER: "1",
|
|
1875
|
+
GH_SPINNER_DISABLED: "1",
|
|
1876
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
1877
|
+
GCM_INTERACTIVE: "never",
|
|
1878
|
+
DEBIAN_FRONTEND: "noninteractive",
|
|
1879
|
+
GIT_ASKPASS: "/bin/true",
|
|
1880
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
1881
|
+
GIT_CONFIG_COUNT: "2",
|
|
1882
|
+
GIT_CONFIG_KEY_0: "credential.helper",
|
|
1883
|
+
GIT_CONFIG_VALUE_0: "",
|
|
1884
|
+
GIT_CONFIG_KEY_1: "http.emptyAuth",
|
|
1885
|
+
GIT_CONFIG_VALUE_1: "true"
|
|
1886
|
+
};
|
|
1887
|
+
function shellQuote(value) {
|
|
1888
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
1889
|
+
}
|
|
1890
|
+
function buildEnvExports(options) {
|
|
1891
|
+
const lines = [];
|
|
1892
|
+
if (options.path) lines.push(`export PATH="${options.path}"`);
|
|
1893
|
+
for (const [key, value] of Object.entries(NON_INTERACTIVE_ENV)) lines.push(`export ${key}=${shellQuote(value)}`);
|
|
1894
|
+
for (const [key, value] of Object.entries(options.env ?? {})) lines.push(`export ${key}=${shellQuote(value)}`);
|
|
1895
|
+
return lines;
|
|
1896
|
+
}
|
|
1897
|
+
/** Render one argv command as a safely quoted shell script. */
|
|
1898
|
+
function buildCommandScript(input) {
|
|
1899
|
+
return [shellQuote(input.cmd), ...(input.args ?? []).map(shellQuote)].join(" ");
|
|
1900
|
+
}
|
|
1901
|
+
/** Build one shell entrypoint that never waits on terminal input. */
|
|
1902
|
+
function buildNonInteractiveShellScript(script, options = {}) {
|
|
1903
|
+
return [
|
|
1904
|
+
...buildEnvExports(options),
|
|
1905
|
+
"exec </dev/null",
|
|
1906
|
+
script
|
|
1907
|
+
].join(" && ");
|
|
1908
|
+
}
|
|
1909
|
+
/** Wrap argv-style commands so every sandbox subprocess runs in non-interactive mode. */
|
|
1910
|
+
function buildNonInteractiveCommand(input) {
|
|
1911
|
+
return {
|
|
1912
|
+
cmd: "bash",
|
|
1913
|
+
args: [input.login ? "-lc" : "-c", buildNonInteractiveShellScript(buildCommandScript(input), {
|
|
1914
|
+
env: input.env,
|
|
1915
|
+
path: input.path
|
|
1916
|
+
})]
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
/** Run a subprocess through one enforced non-interactive entrypoint. */
|
|
1920
|
+
async function runNonInteractiveCommand(sandbox, input) {
|
|
1921
|
+
const command = {
|
|
1922
|
+
...buildNonInteractiveCommand(input),
|
|
1923
|
+
...input.cwd ? { cwd: input.cwd } : void 0,
|
|
1924
|
+
...input.signal ? { signal: input.signal } : void 0,
|
|
1925
|
+
...input.sudo !== void 0 ? { sudo: input.sudo } : void 0,
|
|
1926
|
+
...input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : void 0
|
|
1927
|
+
};
|
|
1928
|
+
return await sandbox.runCommand(command);
|
|
1929
|
+
}
|
|
1835
1930
|
// src/chat/tools/execution/plugin-tool-denied-error.ts
|
|
1836
1931
|
const PLUGIN_DENIAL_INSTRUCTIONS = [
|
|
1837
1932
|
"This action was blocked by policy and was not executed.",
|
|
@@ -1988,6 +2083,26 @@ function runWithWorkspaceTeamId(teamId, fn) {
|
|
|
1988
2083
|
function getWorkspaceTeamId() {
|
|
1989
2084
|
return workspaceTeamIdStorage.getStore();
|
|
1990
2085
|
}
|
|
2086
|
+
// src/chat/sleep.ts
|
|
2087
|
+
/** Wait for a duration, rejecting with the owning signal reason when cancelled. */
|
|
2088
|
+
function sleep$1(ms, signal) {
|
|
2089
|
+
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2090
|
+
return new Promise((resolve, reject) => {
|
|
2091
|
+
if (signal.aborted) {
|
|
2092
|
+
reject(signal.reason);
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
const timeoutId = setTimeout(() => {
|
|
2096
|
+
signal.removeEventListener("abort", onAbort);
|
|
2097
|
+
resolve();
|
|
2098
|
+
}, ms);
|
|
2099
|
+
const onAbort = () => {
|
|
2100
|
+
clearTimeout(timeoutId);
|
|
2101
|
+
reject(signal.reason);
|
|
2102
|
+
};
|
|
2103
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
1991
2106
|
// src/chat/slack/client.ts
|
|
1992
2107
|
var SlackActionError = class extends Error {
|
|
1993
2108
|
code;
|
|
@@ -2209,7 +2324,6 @@ async function withSlackRetries(task, maxAttempts = 3, context = {}) {
|
|
|
2209
2324
|
if (mapped.retryAfterSeconds) errorAttrs["app.slack.retry_after_ms"] = mapped.retryAfterSeconds * 1e3;
|
|
2210
2325
|
} else if (mapped.statusCode != null) errorAttrs["http.response.status_code"] = mapped.statusCode;
|
|
2211
2326
|
setSpanAttributes(errorAttrs);
|
|
2212
|
-
setSpanStatus("error");
|
|
2213
2327
|
throw error;
|
|
2214
2328
|
}
|
|
2215
2329
|
}, {
|
|
@@ -2310,21 +2424,9 @@ async function getFilePermalink(fileId) {
|
|
|
2310
2424
|
}
|
|
2311
2425
|
async function downloadPrivateSlackFile(url) {
|
|
2312
2426
|
const token = resolveSlackToken();
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
if (!response.ok) {
|
|
2317
|
-
setSpanAttributes({ "error.type": String(response.status) });
|
|
2318
|
-
setSpanStatus("error");
|
|
2319
|
-
throw new Error(`Slack file download failed: ${response.status}`);
|
|
2320
|
-
}
|
|
2321
|
-
return Buffer.from(await response.arrayBuffer());
|
|
2322
|
-
}, {
|
|
2323
|
-
"http.request.method": "GET",
|
|
2324
|
-
"server.address": "files.slack.com",
|
|
2325
|
-
"url.scheme": "https",
|
|
2326
|
-
"app.slack.method": "files.download"
|
|
2327
|
-
});
|
|
2427
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
2428
|
+
if (!response.ok) throw new Error(`Slack file download failed: ${response.status}`);
|
|
2429
|
+
return Buffer.from(await response.arrayBuffer());
|
|
2328
2430
|
}
|
|
2329
2431
|
// src/chat/credentials/subject.ts
|
|
2330
2432
|
const CREDENTIAL_SUBJECT_HMAC_CONTEXT = "junior.credential_subject.v1";
|
|
@@ -2336,11 +2438,11 @@ function getCredentialSubjectSecret() {
|
|
|
2336
2438
|
function buildPayload(parts) {
|
|
2337
2439
|
return [CREDENTIAL_SUBJECT_HMAC_CONTEXT, ...parts].join("\0");
|
|
2338
2440
|
}
|
|
2339
|
-
function signPayload(secret, payload) {
|
|
2441
|
+
function signPayload$1(secret, payload) {
|
|
2340
2442
|
const digest = createHmac("sha256", secret).update(payload).digest("hex");
|
|
2341
2443
|
return `${CREDENTIAL_SUBJECT_SIGNATURE_VERSION}=${digest}`;
|
|
2342
2444
|
}
|
|
2343
|
-
function timingSafeMatch(expected, actual) {
|
|
2445
|
+
function timingSafeMatch$1(expected, actual) {
|
|
2344
2446
|
const expectedBuffer = Buffer.from(expected);
|
|
2345
2447
|
const actualBuffer = Buffer.from(actual);
|
|
2346
2448
|
if (expectedBuffer.length !== actualBuffer.length) return false;
|
|
@@ -2374,7 +2476,7 @@ function bindSlackDirectCredentialSubject(input) {
|
|
|
2374
2476
|
type: "slack-direct-conversation",
|
|
2375
2477
|
teamId,
|
|
2376
2478
|
channelId,
|
|
2377
|
-
signature: signPayload(secret, buildPayload([
|
|
2479
|
+
signature: signPayload$1(secret, buildPayload([
|
|
2378
2480
|
subject.allowedWhen,
|
|
2379
2481
|
teamId,
|
|
2380
2482
|
channelId,
|
|
@@ -2391,7 +2493,7 @@ function verifySlackDirectCredentialSubject(input) {
|
|
|
2391
2493
|
const { subject } = input;
|
|
2392
2494
|
const binding = subject.binding;
|
|
2393
2495
|
if (subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "private-direct-conversation" || !binding || binding.type !== "slack-direct-conversation" || typeof binding.signature !== "string" || !binding.signature || binding.teamId !== input.teamId || binding.channelId !== channelId) return false;
|
|
2394
|
-
return timingSafeMatch(signPayload(secret, buildPayload([
|
|
2496
|
+
return timingSafeMatch$1(signPayload$1(secret, buildPayload([
|
|
2395
2497
|
subject.allowedWhen,
|
|
2396
2498
|
binding.teamId,
|
|
2397
2499
|
binding.channelId,
|
|
@@ -2415,7 +2517,7 @@ function bindScheduledAutomationCredentialSubject(input) {
|
|
|
2415
2517
|
type: "scheduled-automation",
|
|
2416
2518
|
plugin,
|
|
2417
2519
|
taskId,
|
|
2418
|
-
signature: signPayload(secret, buildPayload([
|
|
2520
|
+
signature: signPayload$1(secret, buildPayload([
|
|
2419
2521
|
"scheduled-automation",
|
|
2420
2522
|
plugin,
|
|
2421
2523
|
taskId,
|
|
@@ -2430,7 +2532,7 @@ function verifyScheduledAutomationCredentialSubject(input) {
|
|
|
2430
2532
|
const { subject } = input;
|
|
2431
2533
|
const binding = subject.binding;
|
|
2432
2534
|
if (!secret || input.plugin !== "scheduler" || subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "scheduled-automation" || !subject.taskId || binding.type !== "scheduled-automation" || binding.plugin !== input.plugin || binding.taskId !== subject.taskId) return false;
|
|
2433
|
-
return timingSafeMatch(signPayload(secret, buildPayload([
|
|
2535
|
+
return timingSafeMatch$1(signPayload$1(secret, buildPayload([
|
|
2434
2536
|
"scheduled-automation",
|
|
2435
2537
|
binding.plugin,
|
|
2436
2538
|
binding.taskId,
|
|
@@ -2454,7 +2556,7 @@ function bindEventAutomationCredentialSubject(input) {
|
|
|
2454
2556
|
type: "event-automation",
|
|
2455
2557
|
plugin,
|
|
2456
2558
|
taskId,
|
|
2457
|
-
signature: signPayload(secret, buildPayload([
|
|
2559
|
+
signature: signPayload$1(secret, buildPayload([
|
|
2458
2560
|
"event-automation",
|
|
2459
2561
|
plugin,
|
|
2460
2562
|
taskId,
|
|
@@ -2469,7 +2571,7 @@ function verifyEventAutomationCredentialSubject(input) {
|
|
|
2469
2571
|
const { subject } = input;
|
|
2470
2572
|
const binding = subject.binding;
|
|
2471
2573
|
if (!secret || input.plugin !== "junior" || subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "event-automation" || !subject.taskId || binding.type !== "event-automation" || binding.plugin !== input.plugin || binding.taskId !== subject.taskId) return false;
|
|
2472
|
-
return timingSafeMatch(signPayload(secret, buildPayload([
|
|
2574
|
+
return timingSafeMatch$1(signPayload$1(secret, buildPayload([
|
|
2473
2575
|
"event-automation",
|
|
2474
2576
|
binding.plugin,
|
|
2475
2577
|
binding.taskId,
|
|
@@ -2490,7 +2592,7 @@ function bindTimerWatchCredentialSubject(input) {
|
|
|
2490
2592
|
type: "timer-watch",
|
|
2491
2593
|
conversationId,
|
|
2492
2594
|
watchId,
|
|
2493
|
-
signature: signPayload(secret, buildPayload([
|
|
2595
|
+
signature: signPayload$1(secret, buildPayload([
|
|
2494
2596
|
"timer-watch",
|
|
2495
2597
|
conversationId,
|
|
2496
2598
|
watchId,
|
|
@@ -2505,7 +2607,7 @@ function verifyTimerWatchCredentialSubject(input) {
|
|
|
2505
2607
|
const { subject } = input;
|
|
2506
2608
|
const binding = subject.binding;
|
|
2507
2609
|
if (!secret || subject.type !== "user" || !isActorUserId(subject.userId) || subject.allowedWhen !== "timer-watch" || binding.type !== "timer-watch" || binding.conversationId !== input.conversationId || binding.watchId !== input.watchId) return false;
|
|
2508
|
-
return timingSafeMatch(signPayload(secret, buildPayload([
|
|
2610
|
+
return timingSafeMatch$1(signPayload$1(secret, buildPayload([
|
|
2509
2611
|
"timer-watch",
|
|
2510
2612
|
binding.conversationId,
|
|
2511
2613
|
binding.watchId,
|
|
@@ -2970,36 +3072,2143 @@ function workspaceRepoCheckoutPath(repo) {
|
|
|
2970
3072
|
if (!path) throw new Error(`Invalid repository name for checkout path: ${repo}`);
|
|
2971
3073
|
return path;
|
|
2972
3074
|
}
|
|
2973
|
-
// src/chat/sandbox/
|
|
3075
|
+
// src/chat/sandbox/vercel/credentials.ts
|
|
3076
|
+
/**
|
|
3077
|
+
* Resolve explicit Vercel Sandbox credentials, or return undefined to let
|
|
3078
|
+
* the SDK read ambient Vercel auth from its own environment detection.
|
|
3079
|
+
*/
|
|
3080
|
+
function getVercelSandboxCredentials() {
|
|
3081
|
+
const token = toOptionalTrimmed(process.env.VERCEL_TOKEN);
|
|
3082
|
+
const teamId = toOptionalTrimmed(process.env.VERCEL_TEAM_ID);
|
|
3083
|
+
const projectId = toOptionalTrimmed(process.env.VERCEL_PROJECT_ID);
|
|
3084
|
+
if (token && teamId && projectId) return {
|
|
3085
|
+
token,
|
|
3086
|
+
teamId,
|
|
3087
|
+
projectId
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
// src/chat/slack/post-warning.ts
|
|
3091
|
+
const codes = /* @__PURE__ */ new Set([
|
|
3092
|
+
"invalid_metadata_format",
|
|
3093
|
+
"invalid_metadata_schema",
|
|
3094
|
+
"metadata_too_large",
|
|
3095
|
+
"invalid_arguments",
|
|
3096
|
+
"invalid_blocks",
|
|
3097
|
+
"missing_scope",
|
|
3098
|
+
"missing_charset",
|
|
3099
|
+
"superfluous_charset",
|
|
3100
|
+
"message_truncated"
|
|
3101
|
+
]);
|
|
3102
|
+
const pathFields = /* @__PURE__ */ new Set([
|
|
3103
|
+
"metadata",
|
|
3104
|
+
"event_type",
|
|
3105
|
+
"event_payload",
|
|
3106
|
+
"entities",
|
|
3107
|
+
"entity_type",
|
|
3108
|
+
"entity_payload",
|
|
3109
|
+
"external_ref",
|
|
3110
|
+
"id",
|
|
3111
|
+
"type",
|
|
3112
|
+
"url",
|
|
3113
|
+
"attributes",
|
|
3114
|
+
"title",
|
|
3115
|
+
"text",
|
|
3116
|
+
"display_id",
|
|
3117
|
+
"display_type",
|
|
3118
|
+
"product_name",
|
|
3119
|
+
"fields",
|
|
3120
|
+
"status",
|
|
3121
|
+
"custom_fields",
|
|
3122
|
+
"key",
|
|
3123
|
+
"label",
|
|
3124
|
+
"value",
|
|
3125
|
+
"long",
|
|
3126
|
+
"format",
|
|
3127
|
+
"link",
|
|
3128
|
+
"display_order",
|
|
3129
|
+
"app_unfurl_url",
|
|
3130
|
+
"app_id",
|
|
3131
|
+
"created_by",
|
|
3132
|
+
"created_at",
|
|
3133
|
+
"updated_by",
|
|
3134
|
+
"updated_at",
|
|
3135
|
+
"full_size_preview",
|
|
3136
|
+
"preview_url",
|
|
3137
|
+
"mime_type"
|
|
3138
|
+
]);
|
|
3139
|
+
const diagnosticWords = new Set(`WARN ERROR Message message metadata was incorrectly formatted The the will be
|
|
3140
|
+
ignored as a result For for event entity refer to following errors error
|
|
3141
|
+
missing required field fields property properties additional unexpected unknown
|
|
3142
|
+
unsupported invalid expected received actual type types must should match matches
|
|
3143
|
+
matching does not is are has have of at in on and or an only allowed allow
|
|
3144
|
+
value values string number integer boolean object array null minimum maximum
|
|
3145
|
+
length min max items item empty nonempty too long short large small size limit
|
|
3146
|
+
exceeded exceeds less greater than equal valid format schema validation failed
|
|
3147
|
+
failure cannot contain contains found one any all none enum pattern definition
|
|
3148
|
+
defined constraint constraints satisfy satisfies with without provided specified
|
|
3149
|
+
unrecognized unrecognised recognized recognised extraneous disallowed duplicate
|
|
3150
|
+
unique malformed nested root exactly needs need requires requirement supported
|
|
3151
|
+
json-pointer input different schema_uri schema_id schemas additionalProperties
|
|
3152
|
+
minLength maxLength minItems maxItems anyOf oneOf allOf`.split(/\s+/));
|
|
3153
|
+
function summarizeDiagnostic(value) {
|
|
3154
|
+
return value.slice(0, 2e3).replace(/\b(?:https?:\/\/|www\.)[^\s"'`<>]+/gi, "[value]").replace(/\/[^\s"'`[\],;()]*|[\p{L}\p{N}\p{M}_]+(?:[-./:@=+%~][\p{L}\p{N}\p{M}_]+)*|[^\s\w[\]{}():,;."'`/*=<>!?+-]/gu, (token) => {
|
|
3155
|
+
if (token.startsWith("/")) return token.split("/").slice(0, 17).map((part, index) => index === 0 ? "" : pathFields.has(part) ? part : "*").join("/");
|
|
3156
|
+
return codes.has(token) || pathFields.has(token) || diagnosticWords.has(token) ? token : "[value]";
|
|
3157
|
+
}).replace(/\s+/g, " ").slice(0, 2e3);
|
|
3158
|
+
}
|
|
3159
|
+
/** Report Slack warnings once, on the request span, without retrying the post. */
|
|
3160
|
+
function captureSlackPostWarning(response, attributes) {
|
|
3161
|
+
const warnings = response.response_metadata?.warnings ?? [];
|
|
3162
|
+
const messages = response.response_metadata?.messages ?? [];
|
|
3163
|
+
const knownCodes = [...new Set(warnings.slice(0, 20).filter((code) => codes.has(code)))].sort();
|
|
3164
|
+
const diagnostics = {
|
|
3165
|
+
"app.slack.warning_count": warnings.length,
|
|
3166
|
+
"app.slack.response_message_count": messages.length,
|
|
3167
|
+
"app.slack.diagnostic_codes": knownCodes
|
|
3168
|
+
};
|
|
3169
|
+
setSpanAttributes(diagnostics);
|
|
3170
|
+
if (!warnings.length && !messages.length) return;
|
|
3171
|
+
captureMessage("Slack chat.postMessage returned warnings", {
|
|
3172
|
+
level: "warning",
|
|
3173
|
+
fingerprint: ["slack.chat.postMessage.warning", ...knownCodes.length ? knownCodes : ["unknown"]],
|
|
3174
|
+
tags: { "app.slack.method": "chat.postMessage" },
|
|
3175
|
+
extra: {
|
|
3176
|
+
...getLogContextAttributes(),
|
|
3177
|
+
...attributes,
|
|
3178
|
+
...diagnostics,
|
|
3179
|
+
"app.slack.response_diagnostics": {
|
|
3180
|
+
warnings: warnings.slice(0, 20).map(summarizeDiagnostic),
|
|
3181
|
+
messages: messages.slice(0, 20).map(summarizeDiagnostic)
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
});
|
|
3185
|
+
}
|
|
3186
|
+
// src/chat/slack/work-object.ts
|
|
3187
|
+
const url = z.url({ protocol: /^https?$/ });
|
|
3188
|
+
const textField = z.strictObject({
|
|
3189
|
+
type: z.literal("string"),
|
|
3190
|
+
key: z.string().min(1),
|
|
3191
|
+
label: z.string().min(1),
|
|
3192
|
+
value: z.string(),
|
|
3193
|
+
long: z.boolean().optional(),
|
|
3194
|
+
format: z.literal("markdown").optional(),
|
|
3195
|
+
link: url.optional()
|
|
3196
|
+
}).refine((field) => !field.format || !field.link, { message: "Slack string fields cannot combine format and link" });
|
|
3197
|
+
const customField = z.discriminatedUnion("type", [textField, z.strictObject({
|
|
3198
|
+
type: z.literal("slack#/types/timestamp"),
|
|
3199
|
+
key: z.string().min(1),
|
|
3200
|
+
label: z.string().min(1),
|
|
3201
|
+
value: z.number().int(),
|
|
3202
|
+
link: url.optional()
|
|
3203
|
+
})]);
|
|
3204
|
+
const payload = z.strictObject({
|
|
3205
|
+
attributes: z.strictObject({
|
|
3206
|
+
title: z.strictObject({ text: z.string().min(1) }),
|
|
3207
|
+
display_id: z.string().optional(),
|
|
3208
|
+
display_type: z.string().optional(),
|
|
3209
|
+
product_name: z.string().optional(),
|
|
3210
|
+
product_icon: z.strictObject({
|
|
3211
|
+
url,
|
|
3212
|
+
alt_text: z.string().min(1)
|
|
3213
|
+
}).optional()
|
|
3214
|
+
}),
|
|
3215
|
+
custom_fields: z.array(customField).optional(),
|
|
3216
|
+
display_order: z.array(z.string()).optional()
|
|
3217
|
+
});
|
|
3218
|
+
/** Limit IDs to Junior's safe subset; Slack silently rejects JSON punctuation. */
|
|
3219
|
+
const slackExternalRefIdSchema = z.string().min(1).regex(/^[A-Za-z0-9_-]+$/, "Unsupported Slack external reference ID");
|
|
3220
|
+
const entity = z.strictObject({
|
|
3221
|
+
external_ref: z.strictObject({
|
|
3222
|
+
id: slackExternalRefIdSchema,
|
|
3223
|
+
type: z.string().min(1).optional()
|
|
3224
|
+
}),
|
|
3225
|
+
url
|
|
3226
|
+
});
|
|
3227
|
+
/** Validate notification and detail metadata before Slack can ignore bad fields. */
|
|
3228
|
+
const slackEntitySchema = z.discriminatedUnion("entity_type", [entity.extend({
|
|
3229
|
+
entity_type: z.literal("slack#/entities/item"),
|
|
3230
|
+
entity_payload: payload.extend({ fields: z.never().optional() })
|
|
3231
|
+
}), entity.extend({
|
|
3232
|
+
entity_type: z.literal("slack#/entities/task"),
|
|
3233
|
+
entity_payload: payload.extend({ fields: z.strictObject({ status: z.strictObject({ value: z.string() }).optional() }).optional() })
|
|
3234
|
+
})]);
|
|
3235
|
+
// src/chat/slack/outbound.ts
|
|
3236
|
+
const MAX_SLACK_MESSAGE_TEXT_CHARS = 4e4;
|
|
3237
|
+
function requireSlackConversationId(channelId, action) {
|
|
3238
|
+
const normalized = normalizeSlackConversationId(channelId);
|
|
3239
|
+
if (!normalized) throw new Error(`${action} requires a valid channel ID`);
|
|
3240
|
+
return normalized;
|
|
3241
|
+
}
|
|
3242
|
+
function requireSlackThreadTimestamp(threadTs, action) {
|
|
3243
|
+
const normalized = threadTs.trim();
|
|
3244
|
+
if (!normalized) throw new Error(`${action} requires a thread timestamp`);
|
|
3245
|
+
return normalized;
|
|
3246
|
+
}
|
|
3247
|
+
function requireSlackMessageTimestamp(timestamp, action) {
|
|
3248
|
+
const normalized = parseSlackMessageTs(timestamp);
|
|
3249
|
+
if (!normalized) throw new Error(`${action} requires a target message timestamp`);
|
|
3250
|
+
return normalized;
|
|
3251
|
+
}
|
|
3252
|
+
function requireSlackMessageText(text, action) {
|
|
3253
|
+
if (text.trim().length === 0) throw new Error(`${action} requires non-empty text`);
|
|
3254
|
+
if (text.length > MAX_SLACK_MESSAGE_TEXT_CHARS) throw new Error(`${action} text exceeds Slack's 40000 character truncation limit`);
|
|
3255
|
+
return text;
|
|
3256
|
+
}
|
|
3257
|
+
/** Resolve a Slack message permalink without making read-only workflows fail on lookup errors. */
|
|
3258
|
+
async function getSlackMessagePermalink(args) {
|
|
3259
|
+
try {
|
|
3260
|
+
return (await withSlackRetries(() => getSlackClient().chat.getPermalink({
|
|
3261
|
+
channel: args.channelId,
|
|
3262
|
+
message_ts: args.messageTs
|
|
3263
|
+
}), 3, {
|
|
3264
|
+
action: "chat.getPermalink",
|
|
3265
|
+
idempotent: true,
|
|
3266
|
+
spanAttributes: {
|
|
3267
|
+
"app.slack.channel_id": args.channelId,
|
|
3268
|
+
"app.slack.message_ts": args.messageTs
|
|
3269
|
+
}
|
|
3270
|
+
})).permalink;
|
|
3271
|
+
} catch {
|
|
3272
|
+
return;
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
/** Open the bot's direct message with one Slack user and return its channel ID. */
|
|
3276
|
+
async function openSlackDirectMessage(userId) {
|
|
3277
|
+
const channelId = (await withSlackRetries(() => getSlackClient().conversations.open({ users: userId }), 3, { action: "conversations.open" })).channel?.id;
|
|
3278
|
+
if (!channelId) throw new Error("Slack did not return a direct message channel.");
|
|
3279
|
+
return channelId;
|
|
3280
|
+
}
|
|
3281
|
+
/** Post Slack `mrkdwn` text to a conversation or thread via the shared outbound boundary. */
|
|
3282
|
+
async function postSlackMessage(input) {
|
|
3283
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack message posting");
|
|
3284
|
+
const text = requireSlackMessageText(input.text, "Slack message posting");
|
|
3285
|
+
const entities = input.entities?.length ? slackEntitySchema.array().parse(input.entities) : void 0;
|
|
3286
|
+
const threadTs = input.threadTs ? requireSlackThreadTimestamp(input.threadTs, "Slack thread message posting") : void 0;
|
|
3287
|
+
const attributes = {
|
|
3288
|
+
"app.slack.channel_id": channelId,
|
|
3289
|
+
...threadTs ? { "app.slack.thread_ts": threadTs } : void 0,
|
|
3290
|
+
"app.slack.work_object.count": entities?.length ?? 0
|
|
3291
|
+
};
|
|
3292
|
+
const response = await withSlackRetries(async () => {
|
|
3293
|
+
const response = await getSlackClient().chat.postMessage({
|
|
3294
|
+
channel: channelId,
|
|
3295
|
+
text,
|
|
3296
|
+
unfurl_links: false,
|
|
3297
|
+
unfurl_media: false,
|
|
3298
|
+
...input.blocks?.length ? { blocks: input.blocks } : void 0,
|
|
3299
|
+
...entities ? { metadata: { entities } } : void 0,
|
|
3300
|
+
...threadTs ? { thread_ts: threadTs } : void 0
|
|
3301
|
+
});
|
|
3302
|
+
const messageId = parseSlackMessageTs(response.ts);
|
|
3303
|
+
setSpanAttributes({ "messaging.message.id": messageId });
|
|
3304
|
+
captureSlackPostWarning(response, {
|
|
3305
|
+
...attributes,
|
|
3306
|
+
"messaging.message.id": messageId
|
|
3307
|
+
});
|
|
3308
|
+
return response;
|
|
3309
|
+
}, 3, {
|
|
3310
|
+
action: "chat.postMessage",
|
|
3311
|
+
attributes
|
|
3312
|
+
});
|
|
3313
|
+
const messageTs = parseSlackMessageTs(response.ts);
|
|
3314
|
+
if (!messageTs) throw new Error("Slack message posted without ts");
|
|
3315
|
+
return {
|
|
3316
|
+
ts: messageTs,
|
|
3317
|
+
...input.includePermalink ? { permalink: await getSlackMessagePermalink({
|
|
3318
|
+
channelId,
|
|
3319
|
+
messageTs
|
|
3320
|
+
}) } : void 0
|
|
3321
|
+
};
|
|
3322
|
+
}
|
|
3323
|
+
/**
|
|
3324
|
+
* Post an ephemeral Slack message. Delivery is best-effort on Slack's side, but
|
|
3325
|
+
* request validation and Web API behavior are centralized here.
|
|
3326
|
+
*/
|
|
3327
|
+
async function postSlackEphemeralMessage(input) {
|
|
3328
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack ephemeral message posting");
|
|
3329
|
+
const userId = parseSlackUserId(input.userId);
|
|
3330
|
+
if (!userId) throw new Error("Slack ephemeral message posting requires a user ID");
|
|
3331
|
+
const text = requireSlackMessageText(input.text, "Slack ephemeral message posting");
|
|
3332
|
+
const threadTs = input.threadTs ? requireSlackThreadTimestamp(input.threadTs, "Slack ephemeral thread message posting") : void 0;
|
|
3333
|
+
return { messageTs: (await withSlackRetries(() => getSlackClient().chat.postEphemeral({
|
|
3334
|
+
channel: channelId,
|
|
3335
|
+
user: userId,
|
|
3336
|
+
text,
|
|
3337
|
+
...input.blocks?.length ? { blocks: input.blocks } : void 0,
|
|
3338
|
+
...threadTs ? { thread_ts: threadTs } : void 0
|
|
3339
|
+
}), 3, {
|
|
3340
|
+
action: "chat.postEphemeral",
|
|
3341
|
+
spanAttributes: {
|
|
3342
|
+
"app.slack.channel_id": channelId,
|
|
3343
|
+
"app.slack.user_id": userId,
|
|
3344
|
+
...threadTs ? { "app.slack.thread_ts": threadTs } : void 0
|
|
3345
|
+
}
|
|
3346
|
+
})).message_ts };
|
|
3347
|
+
}
|
|
3348
|
+
/** Upload files into a Slack conversation or thread via the shared outbound file boundary. */
|
|
3349
|
+
async function uploadFilesToConversation(input) {
|
|
3350
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack file upload");
|
|
3351
|
+
const threadTs = input.threadTs ? requireSlackThreadTimestamp(input.threadTs, "Slack file upload") : void 0;
|
|
3352
|
+
if (input.files.length === 0) throw new Error("Slack file upload requires at least one file");
|
|
3353
|
+
const fileUploads = input.files.map((file) => {
|
|
3354
|
+
const filename = file.filename.trim();
|
|
3355
|
+
if (!filename) throw new Error("Slack file upload requires every file to have a filename");
|
|
3356
|
+
return {
|
|
3357
|
+
file: file.data,
|
|
3358
|
+
filename
|
|
3359
|
+
};
|
|
3360
|
+
});
|
|
3361
|
+
return { files: ((await withSlackRetries(() => getSlackClient().filesUploadV2({
|
|
3362
|
+
channel_id: channelId,
|
|
3363
|
+
...threadTs ? { thread_ts: threadTs } : void 0,
|
|
3364
|
+
file_uploads: fileUploads
|
|
3365
|
+
}), 3, {
|
|
3366
|
+
action: "filesUploadV2",
|
|
3367
|
+
spanAttributes: {
|
|
3368
|
+
"app.slack.channel_id": channelId,
|
|
3369
|
+
...threadTs ? { "app.slack.thread_ts": threadTs } : void 0
|
|
3370
|
+
}
|
|
3371
|
+
})).files ?? []).flatMap((completion) => (completion.files ?? []).map((file) => file.id ? { id: file.id } : {})) };
|
|
3372
|
+
}
|
|
3373
|
+
/** Add a reaction to a Slack message, treating `already_reacted` as idempotent success. */
|
|
3374
|
+
async function addReactionToMessage(input) {
|
|
3375
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack reaction");
|
|
3376
|
+
const timestamp = requireSlackMessageTimestamp(input.timestamp, "Slack reaction");
|
|
3377
|
+
const emoji = normalizeSlackEmojiName(input.emoji);
|
|
3378
|
+
if (!emoji) throw new Error("Slack reaction requires a valid emoji alias name");
|
|
3379
|
+
try {
|
|
3380
|
+
await withSlackRetries(() => getSlackClient(input.timeoutMs === void 0 ? void 0 : { timeoutMs: input.timeoutMs }).reactions.add({
|
|
3381
|
+
channel: channelId,
|
|
3382
|
+
timestamp,
|
|
3383
|
+
name: emoji
|
|
3384
|
+
}), input.timeoutMs === void 0 ? 3 : 1, {
|
|
3385
|
+
action: "reactions.add",
|
|
3386
|
+
idempotent: true,
|
|
3387
|
+
spanAttributes: {
|
|
3388
|
+
"app.slack.channel_id": channelId,
|
|
3389
|
+
"app.slack.message_ts": timestamp,
|
|
3390
|
+
"app.slack.reaction": emoji
|
|
3391
|
+
}
|
|
3392
|
+
});
|
|
3393
|
+
} catch (error) {
|
|
3394
|
+
if (error instanceof SlackActionError && error.code === "already_reacted") return { ok: true };
|
|
3395
|
+
throw error;
|
|
3396
|
+
}
|
|
3397
|
+
return { ok: true };
|
|
3398
|
+
}
|
|
3399
|
+
/** Remove a reaction from a Slack message, treating `no_reaction` as idempotent success. */
|
|
3400
|
+
async function removeReactionFromMessage(input) {
|
|
3401
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack reaction removal");
|
|
3402
|
+
const timestamp = requireSlackMessageTimestamp(input.timestamp, "Slack reaction removal");
|
|
3403
|
+
const emoji = normalizeSlackEmojiName(input.emoji);
|
|
3404
|
+
if (!emoji) throw new Error("Slack reaction removal requires a valid emoji alias name");
|
|
3405
|
+
try {
|
|
3406
|
+
await withSlackRetries(() => getSlackClient().reactions.remove({
|
|
3407
|
+
channel: channelId,
|
|
3408
|
+
timestamp,
|
|
3409
|
+
name: emoji
|
|
3410
|
+
}), 3, {
|
|
3411
|
+
action: "reactions.remove",
|
|
3412
|
+
idempotent: true,
|
|
3413
|
+
spanAttributes: {
|
|
3414
|
+
"app.slack.channel_id": channelId,
|
|
3415
|
+
"app.slack.message_ts": timestamp,
|
|
3416
|
+
"app.slack.reaction": emoji
|
|
3417
|
+
}
|
|
3418
|
+
});
|
|
3419
|
+
} catch (error) {
|
|
3420
|
+
if (error instanceof SlackActionError && error.code === "no_reaction") return { ok: true };
|
|
3421
|
+
throw error;
|
|
3422
|
+
}
|
|
3423
|
+
return { ok: true };
|
|
3424
|
+
}
|
|
3425
|
+
// src/chat/slack/status-format.ts
|
|
3426
|
+
const SLACK_STATUS_MAX_LENGTH = 50;
|
|
3427
|
+
/** Truncate a status string to fit Slack's 50-char assistant status limit. */
|
|
3428
|
+
function truncateStatusText(text) {
|
|
3429
|
+
const trimmed = text.trim();
|
|
3430
|
+
if (!trimmed) return "";
|
|
3431
|
+
if (trimmed.length <= SLACK_STATUS_MAX_LENGTH) return trimmed;
|
|
3432
|
+
return `${trimmed.slice(0, 47).trimEnd()}...`;
|
|
3433
|
+
}
|
|
3434
|
+
// src/chat/slack/mrkdwn.ts
|
|
3435
|
+
/** Escape dynamic text for Slack mrkdwn without changing intended formatting. */
|
|
3436
|
+
function escapeSlackMrkdwnText(text) {
|
|
3437
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
3438
|
+
}
|
|
3439
|
+
/** Escape a URL for Slack explicit link syntax while preserving query semantics. */
|
|
3440
|
+
function escapeSlackLinkUrl(url) {
|
|
3441
|
+
return url.replaceAll("&", "&").replaceAll("<", "%3C").replaceAll(">", "%3E");
|
|
3442
|
+
}
|
|
3443
|
+
/** Build a Slack explicit link from dynamic URL and label values. */
|
|
3444
|
+
function formatSlackLink(url, label) {
|
|
3445
|
+
return `<${escapeSlackLinkUrl(url)}|${escapeSlackMrkdwnText(label)}>`;
|
|
3446
|
+
}
|
|
3447
|
+
function readInlineCodeSpan(line, start) {
|
|
3448
|
+
if (line[start] !== "`") return;
|
|
3449
|
+
let n = 1;
|
|
3450
|
+
while (line[start + n] === "`") n++;
|
|
3451
|
+
const marker = "`".repeat(n);
|
|
3452
|
+
let search = start + n;
|
|
3453
|
+
while (search < line.length) {
|
|
3454
|
+
const close = line.indexOf(marker, search);
|
|
3455
|
+
if (close === -1) return;
|
|
3456
|
+
const after = close + n;
|
|
3457
|
+
if (line[after] !== "`") return {
|
|
3458
|
+
text: line.slice(start, after),
|
|
3459
|
+
end: after
|
|
3460
|
+
};
|
|
3461
|
+
search = after + 1;
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
function readExistingSlackAngleToken(line, start) {
|
|
3465
|
+
if (line[start] !== "<") return;
|
|
3466
|
+
const close = line.indexOf(">", start + 1);
|
|
3467
|
+
if (close === -1) return;
|
|
3468
|
+
const body = line.slice(start + 1, close);
|
|
3469
|
+
if (/^(?:https?:\/\/|@|#|!)/.test(body)) return {
|
|
3470
|
+
text: line.slice(start, close + 1),
|
|
3471
|
+
end: close + 1
|
|
3472
|
+
};
|
|
3473
|
+
}
|
|
3474
|
+
function readMarkdownLink(line, start) {
|
|
3475
|
+
if (line[start] !== "[") return;
|
|
3476
|
+
const labelEnd = line.indexOf("](", start + 1);
|
|
3477
|
+
if (labelEnd === -1) return;
|
|
3478
|
+
const destStart = labelEnd + 2;
|
|
3479
|
+
if (!line.startsWith("http://", destStart) && !line.startsWith("https://", destStart)) return;
|
|
3480
|
+
const closeParens = line.indexOf(")", destStart);
|
|
3481
|
+
if (closeParens === -1) return;
|
|
3482
|
+
return {
|
|
3483
|
+
text: line.slice(start, closeParens + 1),
|
|
3484
|
+
end: closeParens + 1
|
|
3485
|
+
};
|
|
3486
|
+
}
|
|
3487
|
+
function hasUnmatchedClosingParen(text) {
|
|
3488
|
+
let balance = 0;
|
|
3489
|
+
for (const ch of text) if (ch === "(") balance++;
|
|
3490
|
+
else if (ch === ")") balance--;
|
|
3491
|
+
return balance < 0;
|
|
3492
|
+
}
|
|
3493
|
+
function readBareUrl(line, start) {
|
|
3494
|
+
let end = start;
|
|
3495
|
+
while (end < line.length) {
|
|
3496
|
+
const ch = line.charAt(end);
|
|
3497
|
+
if (/\s/.test(ch) || ch === "<" || ch === ">" || ch === "\"" || ch === "`" || ch === "|" || ch === "*") break;
|
|
3498
|
+
end++;
|
|
3499
|
+
}
|
|
3500
|
+
if (end === start) return;
|
|
3501
|
+
let raw = line.slice(start, end);
|
|
3502
|
+
let suffix = "";
|
|
3503
|
+
const peel = () => {
|
|
3504
|
+
suffix = raw.slice(-1) + suffix;
|
|
3505
|
+
raw = raw.slice(0, -1);
|
|
3506
|
+
};
|
|
3507
|
+
const shouldPeel = () => raw.endsWith("_") || /[.,!?;:]$/.test(raw) || raw.endsWith(")") && hasUnmatchedClosingParen(raw);
|
|
3508
|
+
while (raw.length > 0 && shouldPeel()) peel();
|
|
3509
|
+
if (!/^https?:\/\/.+/.test(raw)) return;
|
|
3510
|
+
return {
|
|
3511
|
+
url: raw,
|
|
3512
|
+
suffix,
|
|
3513
|
+
end
|
|
3514
|
+
};
|
|
3515
|
+
}
|
|
3516
|
+
const BARE_SLACK_USER_ID_RE = /^@([UW](?=[A-Z0-9]*\d)[A-Z0-9]{8,})(?![\w])/;
|
|
3517
|
+
function readBareSlackUserMention(line, start) {
|
|
3518
|
+
if (line[start] !== "@" || /\w/.test(line[start - 1] ?? "")) return;
|
|
3519
|
+
const match = BARE_SLACK_USER_ID_RE.exec(line.slice(start));
|
|
3520
|
+
if (!match) return;
|
|
3521
|
+
return {
|
|
3522
|
+
text: `<@${match[1]}>`,
|
|
3523
|
+
end: start + match[0].length
|
|
3524
|
+
};
|
|
3525
|
+
}
|
|
3526
|
+
/**
|
|
3527
|
+
* Wrap bare http(s) URLs and bare `@U123` Slack user IDs on a single line as
|
|
3528
|
+
* Slack explicit `<url>` links and `<@U123>` mentions.
|
|
3529
|
+
*/
|
|
3530
|
+
function wrapBareReferencesOnLine(line) {
|
|
3531
|
+
let result = "";
|
|
3532
|
+
let i = 0;
|
|
3533
|
+
while (i < line.length) {
|
|
3534
|
+
const codeSpan = readInlineCodeSpan(line, i);
|
|
3535
|
+
if (codeSpan) {
|
|
3536
|
+
result += codeSpan.text;
|
|
3537
|
+
i = codeSpan.end;
|
|
3538
|
+
continue;
|
|
3539
|
+
}
|
|
3540
|
+
const angleToken = readExistingSlackAngleToken(line, i);
|
|
3541
|
+
if (angleToken) {
|
|
3542
|
+
result += angleToken.text;
|
|
3543
|
+
i = angleToken.end;
|
|
3544
|
+
continue;
|
|
3545
|
+
}
|
|
3546
|
+
const mdLink = readMarkdownLink(line, i);
|
|
3547
|
+
if (mdLink) {
|
|
3548
|
+
result += mdLink.text;
|
|
3549
|
+
i = mdLink.end;
|
|
3550
|
+
continue;
|
|
3551
|
+
}
|
|
3552
|
+
const userMention = readBareSlackUserMention(line, i);
|
|
3553
|
+
if (userMention) {
|
|
3554
|
+
result += userMention.text;
|
|
3555
|
+
i = userMention.end;
|
|
3556
|
+
continue;
|
|
3557
|
+
}
|
|
3558
|
+
if (line.startsWith("https://", i) || line.startsWith("http://", i)) {
|
|
3559
|
+
const parsed = readBareUrl(line, i);
|
|
3560
|
+
if (parsed) {
|
|
3561
|
+
result += `<${parsed.url}>${parsed.suffix}`;
|
|
3562
|
+
i = parsed.end;
|
|
3563
|
+
continue;
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
result += line[i];
|
|
3567
|
+
i++;
|
|
3568
|
+
}
|
|
3569
|
+
return result;
|
|
3570
|
+
}
|
|
3571
|
+
/**
|
|
3572
|
+
* Pre-wrap bare http(s) URLs outside fenced code blocks as Slack explicit
|
|
3573
|
+
* links, preventing Slack's auto-linker from consuming adjacent formatting
|
|
3574
|
+
* markers into the URL. Bare `@U123` user IDs become `<@U123>` mentions,
|
|
3575
|
+
* because Slack shows a bare ID as literal text.
|
|
3576
|
+
*
|
|
3577
|
+
* Uses the same fence-toggle rule as `ensureBlockSpacing` so both passes
|
|
3578
|
+
* agree on which lines are code.
|
|
3579
|
+
*/
|
|
3580
|
+
function wrapBareReferences(text) {
|
|
3581
|
+
const lines = text.split("\n");
|
|
3582
|
+
const out = [];
|
|
3583
|
+
let inCodeBlock = false;
|
|
3584
|
+
for (const line of lines) {
|
|
3585
|
+
if (line.trimStart().startsWith("```")) {
|
|
3586
|
+
inCodeBlock = !inCodeBlock;
|
|
3587
|
+
out.push(line);
|
|
3588
|
+
continue;
|
|
3589
|
+
}
|
|
3590
|
+
out.push(inCodeBlock ? line : wrapBareReferencesOnLine(line));
|
|
3591
|
+
}
|
|
3592
|
+
return out.join("\n");
|
|
3593
|
+
}
|
|
3594
|
+
const LIST_ITEM_PATTERN = /^[-*•]\s|^\d+\.\s/;
|
|
3595
|
+
/**
|
|
3596
|
+
* Turn a follow-up paragraph inside a list item into a bullet at the same
|
|
3597
|
+
* indent.
|
|
3598
|
+
*
|
|
3599
|
+
* Slack's `markdown` block keeps only one paragraph per list item. It joins
|
|
3600
|
+
* the other paragraphs to the top-level item's text with no space and renders
|
|
3601
|
+
* nested lists after them. As a bullet, the paragraph keeps its place and
|
|
3602
|
+
* nesting.
|
|
3603
|
+
*/
|
|
3604
|
+
function bulletListFollowUpParagraphs(text) {
|
|
3605
|
+
const lines = text.split("\n");
|
|
3606
|
+
let inCodeBlock = false;
|
|
3607
|
+
let inList = false;
|
|
3608
|
+
for (const [i, line] of lines.entries()) {
|
|
3609
|
+
const trimmed = line.trimStart();
|
|
3610
|
+
const isFence = trimmed.startsWith("```");
|
|
3611
|
+
if (!inCodeBlock && trimmed && trimmed.length === line.length) inList = LIST_ITEM_PATTERN.test(line);
|
|
3612
|
+
else if (!inCodeBlock && inList && trimmed && !isFence && lines[i - 1]?.trim() === "" && !LIST_ITEM_PATTERN.test(trimmed)) lines[i] = `${line.slice(0, line.length - trimmed.length)}- ${trimmed}`;
|
|
3613
|
+
if (isFence) inCodeBlock = !inCodeBlock;
|
|
3614
|
+
}
|
|
3615
|
+
return lines.join("\n");
|
|
3616
|
+
}
|
|
3617
|
+
/** Insert blank lines between content blocks so Slack renders them with visual separation. */
|
|
3618
|
+
function ensureBlockSpacing(text) {
|
|
3619
|
+
const codeBlockPattern = /^```/;
|
|
3620
|
+
const lines = text.split("\n");
|
|
3621
|
+
const result = [];
|
|
3622
|
+
let inCodeBlock = false;
|
|
3623
|
+
for (const line of lines) {
|
|
3624
|
+
if (codeBlockPattern.test(line.trimStart())) {
|
|
3625
|
+
if (!inCodeBlock) {
|
|
3626
|
+
const prev = result.length > 0 ? result[result.length - 1] : void 0;
|
|
3627
|
+
if (prev !== void 0 && prev.trim() !== "") result.push("");
|
|
3628
|
+
}
|
|
3629
|
+
inCodeBlock = !inCodeBlock;
|
|
3630
|
+
result.push(line);
|
|
3631
|
+
continue;
|
|
3632
|
+
}
|
|
3633
|
+
if (inCodeBlock) {
|
|
3634
|
+
result.push(line);
|
|
3635
|
+
continue;
|
|
3636
|
+
}
|
|
3637
|
+
const prev = result.length > 0 ? result[result.length - 1] : void 0;
|
|
3638
|
+
const isListItem = LIST_ITEM_PATTERN.test(line.trimStart());
|
|
3639
|
+
const isListContinuation = prev !== void 0 && !isListItem && /^\s/.test(line) && (/^\s/.test(prev) || LIST_ITEM_PATTERN.test(prev));
|
|
3640
|
+
if (prev !== void 0 && prev.trim() !== "" && line.trim() !== "" && !isListContinuation && !(LIST_ITEM_PATTERN.test(prev.trimStart()) && isListItem)) result.push("");
|
|
3641
|
+
result.push(line);
|
|
3642
|
+
}
|
|
3643
|
+
return result.join("\n");
|
|
3644
|
+
}
|
|
3645
|
+
/**
|
|
3646
|
+
* Normalize model-authored Slack markdown for delivery via `markdown_text`
|
|
3647
|
+
* or `{ type: "markdown" }` blocks.
|
|
3648
|
+
*
|
|
3649
|
+
* Plugins may first rewrite ordinary Markdown. Core then applies Slack-only
|
|
3650
|
+
* delivery shaping: bare URL wrapping, block spacing, and list follow-up
|
|
3651
|
+
* paragraph bullets. Slack reply delivery owns chunking and continuation
|
|
3652
|
+
* markers separately.
|
|
3653
|
+
*/
|
|
3654
|
+
function normalizeSlackReplyMarkdown(text) {
|
|
3655
|
+
let normalized = applyPluginFormatMarkdown(text).replace(/\r\n?/g, "\n").replace(/[ \t]+$/gm, "");
|
|
3656
|
+
normalized = wrapBareReferences(normalized);
|
|
3657
|
+
normalized = ensureBlockSpacing(normalized);
|
|
3658
|
+
normalized = bulletListFollowUpParagraphs(normalized);
|
|
3659
|
+
return normalized.replace(/\n{3,}/g, "\n\n").trim();
|
|
3660
|
+
}
|
|
3661
|
+
/** Normalize assistant status text before handing it to Slack. */
|
|
3662
|
+
function normalizeSlackStatusText(text) {
|
|
3663
|
+
const trimmed = text.trim();
|
|
3664
|
+
if (!trimmed) return "";
|
|
3665
|
+
return truncateStatusText(trimmed.replace(/(?:\.\s*)+$/, "").trim());
|
|
3666
|
+
}
|
|
3667
|
+
/** Render a Slack user mention, which Slack shows as the person's name. */
|
|
3668
|
+
function slackMention(userId) {
|
|
3669
|
+
return `<@${userId}>`;
|
|
3670
|
+
}
|
|
3671
|
+
// src/chat/slack/oauth-authorization-message.ts
|
|
3672
|
+
const ACCOUNT_SCOPE_NOTE = "This connects only your account. I can see only what you can see.";
|
|
3673
|
+
/** Present private OAuth authorization with a clear action and text fallback. */
|
|
3674
|
+
function buildSlackOAuthAuthorizationMessage(args) {
|
|
3675
|
+
return {
|
|
3676
|
+
text: `${formatSlackLink(args.authorizationUrl, args.label)}. ${ACCOUNT_SCOPE_NOTE} ${args.completionText}`,
|
|
3677
|
+
blocks: [{
|
|
3678
|
+
type: "section",
|
|
3679
|
+
text: {
|
|
3680
|
+
type: "mrkdwn",
|
|
3681
|
+
text: `*${escapeSlackMrkdwnText(args.label)}*\n${ACCOUNT_SCOPE_NOTE}\n${escapeSlackMrkdwnText(args.completionText)}`
|
|
3682
|
+
},
|
|
3683
|
+
accessory: {
|
|
3684
|
+
type: "button",
|
|
3685
|
+
text: {
|
|
3686
|
+
type: "plain_text",
|
|
3687
|
+
text: "Connect"
|
|
3688
|
+
},
|
|
3689
|
+
style: "primary",
|
|
3690
|
+
url: args.authorizationUrl,
|
|
3691
|
+
action_id: "oauth_connect"
|
|
3692
|
+
}
|
|
3693
|
+
}]
|
|
3694
|
+
};
|
|
3695
|
+
}
|
|
3696
|
+
// src/chat/oauth-flow.ts
|
|
3697
|
+
const OAUTH_STATE_TTL_MS = 6e5;
|
|
3698
|
+
function optionalString(value) {
|
|
3699
|
+
return typeof value === "string" ? value : void 0;
|
|
3700
|
+
}
|
|
3701
|
+
/** Parse OAuth callback state that was persisted before a provider redirect. */
|
|
3702
|
+
function parseOAuthStatePayload(value) {
|
|
3703
|
+
if (!isRecord$2(value)) return;
|
|
3704
|
+
if (typeof value.userId !== "string" || typeof value.provider !== "string") return;
|
|
3705
|
+
const actor = value.actor === void 0 ? void 0 : actorSchema.safeParse(value.actor);
|
|
3706
|
+
if (value.actor !== void 0 && (!actor || !actor.success)) return;
|
|
3707
|
+
const destination = parseDestination(value.destination);
|
|
3708
|
+
if (value.destination !== void 0 && !destination) return;
|
|
3709
|
+
const source = value.source === void 0 ? void 0 : sourceSchema.safeParse(value.source);
|
|
3710
|
+
if (value.source !== void 0 && (!source || !source.success)) return;
|
|
3711
|
+
return {
|
|
3712
|
+
userId: value.userId,
|
|
3713
|
+
provider: value.provider,
|
|
3714
|
+
...actor?.success ? { actor: actor.data } : void 0,
|
|
3715
|
+
...optionalString(value.channelId) ? { channelId: optionalString(value.channelId) } : void 0,
|
|
3716
|
+
...destination ? { destination } : void 0,
|
|
3717
|
+
...source?.success ? { source: source.data } : void 0,
|
|
3718
|
+
...optionalString(value.threadTs) ? { threadTs: optionalString(value.threadTs) } : void 0,
|
|
3719
|
+
...optionalString(value.resumeConversationId) ? { resumeConversationId: optionalString(value.resumeConversationId) } : void 0,
|
|
3720
|
+
...optionalString(value.resumeSessionId) ? { resumeSessionId: optionalString(value.resumeSessionId) } : void 0,
|
|
3721
|
+
...optionalString(value.scope) ? { scope: optionalString(value.scope) } : void 0
|
|
3722
|
+
};
|
|
3723
|
+
}
|
|
3724
|
+
/** Return the manifest-owned display label for a provider. */
|
|
3725
|
+
function formatProviderLabel(provider) {
|
|
3726
|
+
const displayName = pluginCatalogRuntime.getDisplayName(provider);
|
|
3727
|
+
if (!displayName) throw new Error(`Unknown plugin provider display name: "${provider}"`);
|
|
3728
|
+
return displayName;
|
|
3729
|
+
}
|
|
3730
|
+
/** Resolve the public base URL from environment variables (JUNIOR_BASE_URL or Vercel). */
|
|
3731
|
+
function resolveBaseUrl() {
|
|
3732
|
+
const explicit = process.env.JUNIOR_BASE_URL?.trim();
|
|
3733
|
+
if (explicit) return explicit;
|
|
3734
|
+
const vercelProd = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
|
|
3735
|
+
if (vercelProd) return `https://${vercelProd}`;
|
|
3736
|
+
const vercelUrl = process.env.VERCEL_URL?.trim();
|
|
3737
|
+
if (vercelUrl) return `https://${vercelUrl}`;
|
|
3738
|
+
}
|
|
3739
|
+
/**
|
|
3740
|
+
* Authorization links must only be visible to the requesting user.
|
|
3741
|
+
* Try in-context private delivery first, then fall back to a DM.
|
|
3742
|
+
*/
|
|
3743
|
+
async function deliverPrivateMessage(input) {
|
|
3744
|
+
let client;
|
|
3745
|
+
try {
|
|
3746
|
+
client = getSlackClient();
|
|
3747
|
+
} catch {
|
|
3748
|
+
logWarn("oauth.private_delivery.skipped", { "app.reason": "missing_bot_token" });
|
|
3749
|
+
return false;
|
|
3750
|
+
}
|
|
3751
|
+
if (input.channelId) try {
|
|
3752
|
+
if (isDmChannel(input.channelId)) await postSlackMessage({
|
|
3753
|
+
channelId: input.channelId,
|
|
3754
|
+
text: input.text,
|
|
3755
|
+
blocks: input.blocks,
|
|
3756
|
+
threadTs: input.threadTs
|
|
3757
|
+
});
|
|
3758
|
+
else await postSlackEphemeralMessage({
|
|
3759
|
+
channelId: input.channelId,
|
|
3760
|
+
userId: input.userId,
|
|
3761
|
+
text: input.text,
|
|
3762
|
+
blocks: input.blocks,
|
|
3763
|
+
threadTs: input.threadTs
|
|
3764
|
+
});
|
|
3765
|
+
return "in_context";
|
|
3766
|
+
} catch (error) {
|
|
3767
|
+
logWarn("oauth.private_delivery.failed", {
|
|
3768
|
+
"app.slack.error": error instanceof Error ? error.message : String(error),
|
|
3769
|
+
"app.slack.channel": input.channelId
|
|
3770
|
+
});
|
|
3771
|
+
}
|
|
3772
|
+
try {
|
|
3773
|
+
const dmChannelId = (await withSlackRetries(() => client.conversations.open({ users: input.userId }), 3, {
|
|
3774
|
+
action: "conversations.open",
|
|
3775
|
+
spanAttributes: { "app.slack.user_id": input.userId }
|
|
3776
|
+
})).channel?.id;
|
|
3777
|
+
if (!dmChannelId) {
|
|
3778
|
+
logWarn("oauth.dm.fallback.failed", { "app.reason": "no_dm_channel_id" });
|
|
3779
|
+
return false;
|
|
3780
|
+
}
|
|
3781
|
+
await postSlackMessage({
|
|
3782
|
+
channelId: dmChannelId,
|
|
3783
|
+
text: input.text,
|
|
3784
|
+
blocks: input.blocks
|
|
3785
|
+
});
|
|
3786
|
+
return "fallback_dm";
|
|
3787
|
+
} catch (error) {
|
|
3788
|
+
logWarn("oauth.dm.fallback.failed", { "app.slack.error": error instanceof Error ? error.message : String(error) });
|
|
3789
|
+
return false;
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
/** Present one OAuth request through the active surface's private delivery. */
|
|
3793
|
+
async function deliverOAuthAuthorization(request, input) {
|
|
3794
|
+
if (input.authorization) {
|
|
3795
|
+
await input.authorization.deliver(request);
|
|
3796
|
+
return "in_context";
|
|
3797
|
+
}
|
|
3798
|
+
return await deliverPrivateMessage({
|
|
3799
|
+
channelId: input.channelId,
|
|
3800
|
+
threadTs: input.threadTs,
|
|
3801
|
+
userId: input.userId,
|
|
3802
|
+
...buildSlackOAuthAuthorizationMessage(request)
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
3805
|
+
/** Initiate an OAuth authorization code flow for a provider and deliver the auth link to the user. */
|
|
3806
|
+
async function startOAuthFlow(provider, input) {
|
|
3807
|
+
const providerConfig = pluginCatalogRuntime.getOAuthConfig(provider);
|
|
3808
|
+
if (!providerConfig) return {
|
|
3809
|
+
ok: false,
|
|
3810
|
+
error: `Provider "${provider}" does not support OAuth authorization`
|
|
3811
|
+
};
|
|
3812
|
+
const clientId = process.env[providerConfig.clientIdEnv]?.trim();
|
|
3813
|
+
if (!clientId) return {
|
|
3814
|
+
ok: false,
|
|
3815
|
+
error: `Missing ${providerConfig.clientIdEnv} environment variable`
|
|
3816
|
+
};
|
|
3817
|
+
const baseUrl = resolveBaseUrl();
|
|
3818
|
+
if (!baseUrl) return {
|
|
3819
|
+
ok: false,
|
|
3820
|
+
error: "Cannot determine base URL (set JUNIOR_BASE_URL or deploy to Vercel)"
|
|
3821
|
+
};
|
|
3822
|
+
const state = input.authorization ? await input.authorization.createState() : randomBytes(32).toString("hex");
|
|
3823
|
+
const requestedScope = input.scope ?? providerConfig.scope;
|
|
3824
|
+
await getStateAdapter().set(`oauth-state:${state}`, {
|
|
3825
|
+
userId: input.actorId,
|
|
3826
|
+
provider,
|
|
3827
|
+
...input.actor ? { actor: input.actor } : void 0,
|
|
3828
|
+
...input.channelId ? { channelId: input.channelId } : void 0,
|
|
3829
|
+
...input.destination ? { destination: input.destination } : void 0,
|
|
3830
|
+
...input.source ? { source: input.source } : void 0,
|
|
3831
|
+
...input.threadTs ? { threadTs: input.threadTs } : void 0,
|
|
3832
|
+
...input.resumeConversationId ? { resumeConversationId: input.resumeConversationId } : void 0,
|
|
3833
|
+
...input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : void 0,
|
|
3834
|
+
...requestedScope ? { scope: requestedScope } : void 0
|
|
3835
|
+
}, OAUTH_STATE_TTL_MS);
|
|
3836
|
+
const authorizeParams = new URLSearchParams({
|
|
3837
|
+
client_id: clientId,
|
|
3838
|
+
state,
|
|
3839
|
+
redirect_uri: `${baseUrl}${providerConfig.callbackPath}`,
|
|
3840
|
+
response_type: "code"
|
|
3841
|
+
});
|
|
3842
|
+
if (requestedScope) authorizeParams.set("scope", requestedScope);
|
|
3843
|
+
for (const [key, value] of Object.entries(providerConfig.authorizeParams ?? {})) authorizeParams.set(key, value);
|
|
3844
|
+
logInfo("jr_rpc.oauth.started", {
|
|
3845
|
+
"app.credential.provider": provider,
|
|
3846
|
+
...input.activeSkillName ? { "app.skill.name": input.activeSkillName } : void 0
|
|
3847
|
+
});
|
|
3848
|
+
return {
|
|
3849
|
+
ok: true,
|
|
3850
|
+
delivery: await deliverOAuthAuthorization({
|
|
3851
|
+
authorizationUrl: `${providerConfig.authorizeEndpoint}?${authorizeParams.toString()}`,
|
|
3852
|
+
label: `Connect your ${formatProviderLabel(provider)} account`,
|
|
3853
|
+
completionText: input.resumeSessionId ? "Once you've authorized, I'll continue automatically." : "Once you've authorized, you'll see a confirmation in Slack."
|
|
3854
|
+
}, {
|
|
3855
|
+
authorization: input.authorization,
|
|
3856
|
+
channelId: input.channelId,
|
|
3857
|
+
threadTs: input.threadTs,
|
|
3858
|
+
userId: input.actorId
|
|
3859
|
+
})
|
|
3860
|
+
};
|
|
3861
|
+
}
|
|
3862
|
+
// src/chat/sandbox/egress/oidc.ts
|
|
3863
|
+
const OIDC_DISCOVERY_CACHE_TTL_MS = 36e5;
|
|
3864
|
+
const OIDC_DISCOVERY_CACHE_MAX_ENTRIES = 8;
|
|
3865
|
+
const jwksByIssuer = /* @__PURE__ */ new Map();
|
|
3866
|
+
function buildDiscoveryUrl(issuer) {
|
|
3867
|
+
const url = new URL(issuer);
|
|
3868
|
+
if (url.protocol !== "https:" || url.hostname !== "oidc.vercel.com") throw new Error("Unexpected Vercel OIDC issuer");
|
|
3869
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
|
3870
|
+
url.search = "";
|
|
3871
|
+
url.hash = "";
|
|
3872
|
+
return url;
|
|
3873
|
+
}
|
|
3874
|
+
function buildJwksUrl(value) {
|
|
3875
|
+
const url = new URL(value);
|
|
3876
|
+
if (url.protocol !== "https:") throw new Error("Vercel OIDC discovery jwks_uri must use HTTPS");
|
|
3877
|
+
return url;
|
|
3878
|
+
}
|
|
3879
|
+
async function getJwks(issuer) {
|
|
3880
|
+
const now = Date.now();
|
|
3881
|
+
const cached = jwksByIssuer.get(issuer);
|
|
3882
|
+
if (cached && cached.expiresAtMs > now) return cached.jwks;
|
|
3883
|
+
if (cached) jwksByIssuer.delete(issuer);
|
|
3884
|
+
const response = await fetch(buildDiscoveryUrl(issuer), { redirect: "error" });
|
|
3885
|
+
if (!response.ok) throw new Error("Unable to load Vercel OIDC discovery metadata");
|
|
3886
|
+
const config = await response.json();
|
|
3887
|
+
if (!config.jwks_uri) throw new Error("Vercel OIDC discovery metadata did not include jwks_uri");
|
|
3888
|
+
const jwks = createRemoteJWKSet(buildJwksUrl(config.jwks_uri));
|
|
3889
|
+
if (!jwksByIssuer.has(issuer) && jwksByIssuer.size >= OIDC_DISCOVERY_CACHE_MAX_ENTRIES) {
|
|
3890
|
+
const oldestIssuer = jwksByIssuer.keys().next().value;
|
|
3891
|
+
if (oldestIssuer) jwksByIssuer.delete(oldestIssuer);
|
|
3892
|
+
}
|
|
3893
|
+
jwksByIssuer.set(issuer, {
|
|
3894
|
+
jwks,
|
|
3895
|
+
expiresAtMs: now + OIDC_DISCOVERY_CACHE_TTL_MS
|
|
3896
|
+
});
|
|
3897
|
+
return jwks;
|
|
3898
|
+
}
|
|
3899
|
+
/**
|
|
3900
|
+
* Verify Vercel signed this Sandbox firewall proxy request for the active VM session.
|
|
3901
|
+
*
|
|
3902
|
+
* `currentDate` is the time for the `exp` and `nbf` checks. It defaults to
|
|
3903
|
+
* now. Evals pass the real time because they replace `Date`.
|
|
3904
|
+
*/
|
|
3905
|
+
async function verifyVercelSandboxOidcToken(token, options = {}) {
|
|
3906
|
+
const unverified = decodeJwt(token);
|
|
3907
|
+
if (typeof unverified.iss !== "string") throw new Error("Vercel OIDC token did not include an issuer");
|
|
3908
|
+
const jwks = await getJwks(unverified.iss);
|
|
3909
|
+
return (await jwtVerify(token, jwks, {
|
|
3910
|
+
issuer: unverified.iss,
|
|
3911
|
+
...options.currentDate ? { currentDate: options.currentDate } : void 0
|
|
3912
|
+
})).payload;
|
|
3913
|
+
}
|
|
3914
|
+
// src/chat/sandbox/egress/tracing.ts
|
|
3915
|
+
function isValidDomainPattern(domain) {
|
|
3916
|
+
if (domain.includes("*")) return domain.startsWith("*.") && domain.indexOf("*", 1) === -1;
|
|
3917
|
+
return true;
|
|
3918
|
+
}
|
|
3919
|
+
/** Normalize exact and leading-wildcard sandbox egress trace domains. */
|
|
3920
|
+
function normalizeSandboxEgressTracePropagationDomains(domains) {
|
|
3921
|
+
if (domains === void 0) return [];
|
|
3922
|
+
if (!Array.isArray(domains)) throw new Error("sandbox.egressTracePropagationDomains must be an array");
|
|
3923
|
+
return [...new Set(domains.map((domain) => {
|
|
3924
|
+
if (typeof domain !== "string") throw new Error("sandbox.egressTracePropagationDomains entries must be strings");
|
|
3925
|
+
const normalized = domain.trim().toLowerCase();
|
|
3926
|
+
if (!normalized) throw new Error("sandbox.egressTracePropagationDomains entries must be non-empty");
|
|
3927
|
+
if (!isValidDomainPattern(normalized)) throw new Error("sandbox.egressTracePropagationDomains entries must be exact domains or leading wildcard domains");
|
|
3928
|
+
return normalized;
|
|
3929
|
+
}))].sort((left, right) => left.localeCompare(right));
|
|
3930
|
+
}
|
|
3931
|
+
/** Return whether a host may carry sandbox egress trace propagation headers. */
|
|
3932
|
+
function shouldPropagateSandboxEgressTrace(host, config = {}) {
|
|
3933
|
+
const normalizedHost = host.trim().toLowerCase();
|
|
3934
|
+
return (config.domains ?? []).some((domain) => {
|
|
3935
|
+
if (domain.startsWith("*.")) {
|
|
3936
|
+
const suffix = domain.slice(1);
|
|
3937
|
+
return normalizedHost.endsWith(suffix) && normalizedHost !== domain.slice(2);
|
|
3938
|
+
}
|
|
3939
|
+
return domain === normalizedHost;
|
|
3940
|
+
});
|
|
3941
|
+
}
|
|
3942
|
+
// src/chat/sandbox/egress/policy.ts
|
|
3943
|
+
/** Return whether an outbound host is covered by a provider domain rule. */
|
|
3944
|
+
function matchesSandboxEgressDomain(host, domain) {
|
|
3945
|
+
return host.toLowerCase() === domain.toLowerCase();
|
|
3946
|
+
}
|
|
3947
|
+
function manifestDomains(manifest) {
|
|
3948
|
+
return [.../* @__PURE__ */ new Set([...manifest.credentials?.domains ?? [], ...manifest.domains ?? []])].sort((left, right) => left.localeCompare(right));
|
|
3949
|
+
}
|
|
3950
|
+
function providerEntries() {
|
|
3951
|
+
return pluginCatalogRuntime.getProviders().map((plugin) => ({
|
|
3952
|
+
provider: plugin.manifest.name,
|
|
3953
|
+
domains: manifestDomains(plugin.manifest)
|
|
3954
|
+
})).filter((entry) => entry.domains.length > 0).sort((left, right) => left.provider.localeCompare(right.provider));
|
|
3955
|
+
}
|
|
3956
|
+
/**
|
|
3957
|
+
* Resolve the plugin provider responsible for an outbound sandbox host.
|
|
3958
|
+
*
|
|
3959
|
+
* Only plugin-declared provider domains are eligible for credentialed sandbox
|
|
3960
|
+
* egress. Hosts without a matching provider are blocked by the proxy even if a
|
|
3961
|
+
* malformed or stale network policy tried to forward them.
|
|
3962
|
+
*/
|
|
3963
|
+
function resolveSandboxEgressProviderForHost(host) {
|
|
3964
|
+
return providerEntries().find((entry) => entry.domains.some((domain) => matchesSandboxEgressDomain(host, domain)))?.provider;
|
|
3965
|
+
}
|
|
3966
|
+
/**
|
|
3967
|
+
* Build the provider-neutral egress policy for one sandbox command context.
|
|
3968
|
+
*
|
|
3969
|
+
* Provider domains go through Junior's credentialed egress when a signed
|
|
3970
|
+
* credential token is given. The token binds each request to the actor and
|
|
3971
|
+
* sandbox VM that created the command. Configured trace-only domains get trace
|
|
3972
|
+
* headers in place. The sandbox receives placeholder env vars, not provider
|
|
3973
|
+
* credentials.
|
|
3974
|
+
*/
|
|
3975
|
+
function buildSandboxEgressNetworkPolicy(input) {
|
|
3976
|
+
const credentialToken = input?.credentialToken;
|
|
3977
|
+
const credentialDomains = credentialToken ? [...new Set(providerEntries().flatMap((entry) => entry.domains))].sort((left, right) => left.localeCompare(right)) : [];
|
|
3978
|
+
const traceHeaders = Object.fromEntries(Object.entries(input?.traceHeaders ?? {}).filter(([, value]) => typeof value === "string" && value.trim()));
|
|
3979
|
+
const traceDomains = Object.keys(traceHeaders).length > 0 ? [...new Set(input?.traceConfig?.domains ?? [])].filter((domain) => !credentialDomains.includes(domain) && shouldPropagateSandboxEgressTrace(domain, input?.traceConfig)).sort((left, right) => left.localeCompare(right)) : [];
|
|
3980
|
+
return {
|
|
3981
|
+
...credentialToken && credentialDomains.length > 0 ? { credentialEgress: {
|
|
3982
|
+
credentialToken,
|
|
3983
|
+
domains: credentialDomains
|
|
3984
|
+
} } : void 0,
|
|
3985
|
+
...traceDomains.length > 0 ? { traceHeaders: {
|
|
3986
|
+
domains: traceDomains,
|
|
3987
|
+
headers: traceHeaders
|
|
3988
|
+
} } : void 0
|
|
3989
|
+
};
|
|
3990
|
+
}
|
|
3991
|
+
/**
|
|
3992
|
+
* Resolve non-secret command environment values for registered sandbox providers.
|
|
3993
|
+
*
|
|
3994
|
+
* Credential env vars are placeholders that tell tools which auth variable name
|
|
3995
|
+
* exists; real credential headers are issued lazily by host egress.
|
|
3996
|
+
*/
|
|
3997
|
+
async function resolveSandboxCommandEnvironment() {
|
|
3998
|
+
const env = {};
|
|
3999
|
+
for (const plugin of pluginCatalogRuntime.getProviders().sort((left, right) => left.manifest.name.localeCompare(right.manifest.name))) {
|
|
4000
|
+
Object.assign(env, resolvePluginCommandEnv(plugin.manifest));
|
|
4001
|
+
const credentials = plugin.manifest.credentials;
|
|
4002
|
+
if (credentials?.authTokenEnv) env[credentials.authTokenEnv] = resolveAuthTokenPlaceholder(credentials);
|
|
4003
|
+
}
|
|
4004
|
+
return env;
|
|
4005
|
+
}
|
|
4006
|
+
// src/chat/sandbox/egress/schemas.ts
|
|
4007
|
+
const finiteNumberSchema = z.number().refine(Number.isFinite);
|
|
4008
|
+
const httpStatusSchema = z.number().int().min(100).max(599);
|
|
4009
|
+
const providerNameSchema = z.string().regex(/^[a-z][a-z0-9-]*$/);
|
|
4010
|
+
const credentialSignalKindSchema = z.enum(["auth_required", "unavailable"]);
|
|
4011
|
+
const sandboxEgressGrantSchema = pluginGrantSchema;
|
|
4012
|
+
const sandboxEgressCredentialContextSchema = z.object({
|
|
4013
|
+
credentials: credentialContextSchema,
|
|
4014
|
+
/** Conversation that the credentials act for. Telemetry only; it grants nothing. */
|
|
4015
|
+
conversationId: z.string().min(1).optional(),
|
|
4016
|
+
egressId: z.string().min(1),
|
|
4017
|
+
expiresAtMs: finiteNumberSchema,
|
|
4018
|
+
contextId: z.string().min(1)
|
|
4019
|
+
}).strict();
|
|
4020
|
+
const sandboxEgressCredentialLeaseSchema = z.object({
|
|
4021
|
+
account: pluginProviderAccountSchema.optional(),
|
|
4022
|
+
authorization: pluginAuthorizationSchema.optional(),
|
|
4023
|
+
grant: sandboxEgressGrantSchema,
|
|
4024
|
+
provider: providerNameSchema,
|
|
4025
|
+
expiresAt: z.string().min(1),
|
|
4026
|
+
headerTransforms: z.array(pluginCredentialHeaderTransformSchema).min(1)
|
|
4027
|
+
}).strict();
|
|
4028
|
+
const sandboxEgressAuthRequiredSignalSchema = z.object({
|
|
4029
|
+
authorization: pluginAuthorizationSchema.optional(),
|
|
4030
|
+
grant: sandboxEgressGrantSchema,
|
|
4031
|
+
kind: credentialSignalKindSchema.default("auth_required"),
|
|
4032
|
+
provider: providerNameSchema,
|
|
4033
|
+
message: z.string().optional(),
|
|
4034
|
+
createdAtMs: finiteNumberSchema
|
|
4035
|
+
}).strict().superRefine((signal, ctx) => {
|
|
4036
|
+
if (signal.authorization && signal.authorization.provider !== signal.provider) ctx.addIssue({
|
|
4037
|
+
code: z.ZodIssueCode.custom,
|
|
4038
|
+
message: "Auth signal authorization provider must match provider",
|
|
4039
|
+
path: ["authorization", "provider"]
|
|
4040
|
+
});
|
|
4041
|
+
});
|
|
4042
|
+
const sandboxEgressPermissionDeniedSignalSchema = z.object({
|
|
4043
|
+
account: pluginProviderAccountSchema.optional(),
|
|
4044
|
+
grant: sandboxEgressGrantSchema,
|
|
4045
|
+
message: z.string().min(1),
|
|
4046
|
+
provider: providerNameSchema,
|
|
4047
|
+
source: z.literal("upstream"),
|
|
4048
|
+
status: httpStatusSchema,
|
|
4049
|
+
upstreamHost: z.string().min(1),
|
|
4050
|
+
upstreamPath: z.string().min(1),
|
|
4051
|
+
createdAtMs: finiteNumberSchema
|
|
4052
|
+
}).strict();
|
|
4053
|
+
const sandboxEgressSignalsResponseSchema = z.object({
|
|
4054
|
+
auth_required: sandboxEgressAuthRequiredSignalSchema.optional(),
|
|
4055
|
+
permission_denied: sandboxEgressPermissionDeniedSignalSchema.optional()
|
|
4056
|
+
}).strict();
|
|
4057
|
+
/** Parse a host-owned sandbox egress auth signal from state or tool results. */
|
|
4058
|
+
function parseSandboxEgressAuthRequiredSignal(value) {
|
|
4059
|
+
const result = sandboxEgressAuthRequiredSignalSchema.safeParse(value);
|
|
4060
|
+
return result.success ? result.data : void 0;
|
|
4061
|
+
}
|
|
4062
|
+
/** Parse a host-owned sandbox egress permission-denied signal from state or tool results. */
|
|
4063
|
+
function parseSandboxEgressPermissionDeniedSignal(value) {
|
|
4064
|
+
const result = sandboxEgressPermissionDeniedSignalSchema.safeParse(value);
|
|
4065
|
+
return result.success ? result.data : void 0;
|
|
4066
|
+
}
|
|
4067
|
+
// src/chat/sandbox/egress/session.ts
|
|
4068
|
+
const SANDBOX_EGRESS_PROXY_PATH = "/api/internal/sandbox-egress";
|
|
4069
|
+
const SANDBOX_EGRESS_TOKEN_VERSION = "v1";
|
|
4070
|
+
const SANDBOX_EGRESS_HMAC_CONTEXT = "junior.sandbox_egress.v1";
|
|
4071
|
+
const SANDBOX_EGRESS_AUTH_SIGNAL_PREFIX = "sandbox-egress-auth-required";
|
|
4072
|
+
const SANDBOX_EGRESS_PERMISSION_SIGNAL_PREFIX = "sandbox-egress-permission-denied";
|
|
4073
|
+
const SANDBOX_EGRESS_LEASE_PREFIX = "sandbox-egress-lease";
|
|
4074
|
+
const DEFAULT_SESSION_TTL_MS = 18e5;
|
|
4075
|
+
/** Treat a cached lease as expired this long before its provider expiry. */
|
|
4076
|
+
const LEASE_REFRESH_BUFFER_MS = 3e5;
|
|
4077
|
+
/** True when this grant may keep shared auth headers on the host. */
|
|
4078
|
+
function isSharedInstallationGrant(grant) {
|
|
4079
|
+
return grant.name.startsWith("installation-");
|
|
4080
|
+
}
|
|
4081
|
+
/**
|
|
4082
|
+
* Build the host key for a shared installation grant.
|
|
4083
|
+
*
|
|
4084
|
+
* Only installation grants are remembered. User and other grants issue live
|
|
4085
|
+
* headers each time, so they never write a host key.
|
|
4086
|
+
*/
|
|
4087
|
+
function leaseKey(provider, grant) {
|
|
4088
|
+
return `${SANDBOX_EGRESS_LEASE_PREFIX}:${provider}:${grant.name}:shared`;
|
|
4089
|
+
}
|
|
4090
|
+
/**
|
|
4091
|
+
* Build the command signal key for credential issuance failures by access level.
|
|
4092
|
+
*/
|
|
4093
|
+
function authSignalKey(egressId, access) {
|
|
4094
|
+
return `${SANDBOX_EGRESS_AUTH_SIGNAL_PREFIX}:${egressId}:${access}`;
|
|
4095
|
+
}
|
|
4096
|
+
/**
|
|
4097
|
+
* Build the command signal key for upstream permission denials by access level.
|
|
4098
|
+
*/
|
|
4099
|
+
function permissionSignalKey(egressId, access) {
|
|
4100
|
+
return `${SANDBOX_EGRESS_PERMISSION_SIGNAL_PREFIX}:${egressId}:${access}`;
|
|
4101
|
+
}
|
|
4102
|
+
function base64Url(input) {
|
|
4103
|
+
return Buffer.from(input, "utf8").toString("base64url");
|
|
4104
|
+
}
|
|
4105
|
+
function fromBase64Url(input) {
|
|
4106
|
+
return Buffer.from(input, "base64url").toString("utf8");
|
|
4107
|
+
}
|
|
4108
|
+
/**
|
|
4109
|
+
* Sign a versioned context payload in the sandbox egress HMAC domain.
|
|
4110
|
+
*
|
|
4111
|
+
* `JUNIOR_SECRET` is the shared secret so proxy URLs cannot be forged by code
|
|
4112
|
+
* running inside the sandbox.
|
|
4113
|
+
*/
|
|
4114
|
+
function signPayload(payload) {
|
|
4115
|
+
return createHmac("sha256", getSandboxEgressSecret()).update(`${SANDBOX_EGRESS_HMAC_CONTEXT}:${payload}`).digest("base64url");
|
|
4116
|
+
}
|
|
4117
|
+
function timingSafeMatch(expected, actual) {
|
|
4118
|
+
const expectedBuffer = Buffer.from(expected);
|
|
4119
|
+
const actualBuffer = Buffer.from(actual);
|
|
4120
|
+
if (expectedBuffer.length !== actualBuffer.length) return false;
|
|
4121
|
+
return timingSafeEqual(expectedBuffer, actualBuffer);
|
|
4122
|
+
}
|
|
4123
|
+
function parseSandboxEgressContext(value) {
|
|
4124
|
+
const result = sandboxEgressCredentialContextSchema.safeParse(value);
|
|
4125
|
+
if (!result.success) return;
|
|
4126
|
+
if (result.data.expiresAtMs <= Date.now()) return;
|
|
4127
|
+
return result.data;
|
|
4128
|
+
}
|
|
4129
|
+
function parseLease(value) {
|
|
4130
|
+
const result = sandboxEgressCredentialLeaseSchema.safeParse(value);
|
|
4131
|
+
if (!result.success) return;
|
|
4132
|
+
const expiresAtMs = Date.parse(result.data.expiresAt);
|
|
4133
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now() + LEASE_REFRESH_BUFFER_MS) return;
|
|
4134
|
+
return result.data;
|
|
4135
|
+
}
|
|
4136
|
+
function getSandboxEgressSecret() {
|
|
4137
|
+
const secret = process.env.JUNIOR_SECRET?.trim();
|
|
4138
|
+
if (secret) return secret;
|
|
4139
|
+
throw new Error("Cannot determine sandbox egress secret (set JUNIOR_SECRET)");
|
|
4140
|
+
}
|
|
4141
|
+
/**
|
|
4142
|
+
* Create the signed Junior credential context embedded in the proxy URL.
|
|
4143
|
+
*
|
|
4144
|
+
* The token binds actor credentials to a specific sandbox egress id and a
|
|
4145
|
+
* short expiry. Vercel OIDC proves which VM sent a request; this token proves
|
|
4146
|
+
* which Junior actor and credential context that VM is allowed to use.
|
|
4147
|
+
*/
|
|
4148
|
+
function createSandboxEgressCredentialToken(input) {
|
|
4149
|
+
const ttlMs = Math.max(1, input.ttlMs ?? DEFAULT_SESSION_TTL_MS);
|
|
4150
|
+
const now = Date.now();
|
|
4151
|
+
const context = {
|
|
4152
|
+
...input.conversationId ? { conversationId: input.conversationId } : void 0,
|
|
4153
|
+
credentials: input.credentials,
|
|
4154
|
+
egressId: input.egressId,
|
|
4155
|
+
expiresAtMs: now + ttlMs,
|
|
4156
|
+
contextId: randomUUID()
|
|
4157
|
+
};
|
|
4158
|
+
const payload = `${SANDBOX_EGRESS_TOKEN_VERSION}.${base64Url(JSON.stringify(context))}`;
|
|
4159
|
+
return `${payload}.${signPayload(payload)}`;
|
|
4160
|
+
}
|
|
4161
|
+
/**
|
|
4162
|
+
* Verify the signed Junior credential context from the proxy URL.
|
|
4163
|
+
*
|
|
4164
|
+
* Returning `undefined` means the token is missing, expired, malformed, or not
|
|
4165
|
+
* signed with the current `JUNIOR_SECRET`.
|
|
4166
|
+
*/
|
|
4167
|
+
function parseSandboxEgressCredentialToken(token) {
|
|
4168
|
+
if (!token) return;
|
|
4169
|
+
const parts = token.split(".");
|
|
4170
|
+
if (parts.length !== 3 || parts[0] !== SANDBOX_EGRESS_TOKEN_VERSION) return;
|
|
4171
|
+
const encodedSession = parts[1];
|
|
4172
|
+
const signature = parts[2];
|
|
4173
|
+
if (!encodedSession || !signature) return;
|
|
4174
|
+
if (!timingSafeMatch(signPayload(`${parts[0]}.${encodedSession}`), signature)) return;
|
|
4175
|
+
try {
|
|
4176
|
+
return parseSandboxEgressContext(JSON.parse(fromBase64Url(encodedSession)));
|
|
4177
|
+
} catch {
|
|
4178
|
+
return;
|
|
4179
|
+
}
|
|
4180
|
+
}
|
|
4181
|
+
/**
|
|
4182
|
+
* Remember auth headers for a shared installation grant.
|
|
4183
|
+
*
|
|
4184
|
+
* User and other grants are ignored here. TTL follows the provider expiry.
|
|
4185
|
+
* Sandbox context expiry still decides whether a request may use the headers;
|
|
4186
|
+
* it does not shorten a shared installation grant for other sandboxes.
|
|
4187
|
+
*/
|
|
4188
|
+
async function setSandboxEgressCredentialLease(_context, lease) {
|
|
4189
|
+
if (!isSharedInstallationGrant(lease.grant)) return;
|
|
4190
|
+
const leaseExpiresAtMs = Date.parse(lease.expiresAt);
|
|
4191
|
+
if (!Number.isFinite(leaseExpiresAtMs) || leaseExpiresAtMs <= Date.now()) return;
|
|
4192
|
+
const ttlMs = Math.max(1, leaseExpiresAtMs - Date.now());
|
|
4193
|
+
const state = getStateAdapter();
|
|
4194
|
+
await state.connect();
|
|
4195
|
+
await state.set(leaseKey(lease.provider, lease.grant), lease, ttlMs);
|
|
4196
|
+
}
|
|
4197
|
+
/** Load remembered auth headers for a shared installation grant. */
|
|
4198
|
+
async function getSandboxEgressCredentialLease(provider, grant, _context) {
|
|
4199
|
+
if (!isSharedInstallationGrant(grant)) return;
|
|
4200
|
+
const state = getStateAdapter();
|
|
4201
|
+
await state.connect();
|
|
4202
|
+
return parseLease(await state.get(leaseKey(provider, grant)));
|
|
4203
|
+
}
|
|
4204
|
+
/** Drop remembered installation headers after the upstream rejects them. */
|
|
4205
|
+
async function clearSandboxEgressCredentialLease(provider, grant, _context) {
|
|
4206
|
+
if (!isSharedInstallationGrant(grant)) return;
|
|
4207
|
+
const state = getStateAdapter();
|
|
4208
|
+
await state.connect();
|
|
4209
|
+
await state.delete(leaseKey(provider, grant));
|
|
4210
|
+
}
|
|
4211
|
+
/**
|
|
4212
|
+
* Record that credential issuance needs user authorization for this command.
|
|
4213
|
+
*
|
|
4214
|
+
* The command runner consumes this after the sandbox tool finishes so the agent
|
|
4215
|
+
* can present a normal authorization-required response instead of a raw proxy
|
|
4216
|
+
* failure.
|
|
4217
|
+
*/
|
|
4218
|
+
async function setSandboxEgressAuthRequiredSignal(context, signal) {
|
|
4219
|
+
const ttlMs = Math.max(1, context.expiresAtMs - Date.now());
|
|
4220
|
+
const state = getStateAdapter();
|
|
4221
|
+
await state.connect();
|
|
4222
|
+
await state.set(authSignalKey(context.egressId, signal.grant.access), {
|
|
4223
|
+
...signal,
|
|
4224
|
+
createdAtMs: Date.now()
|
|
4225
|
+
}, ttlMs);
|
|
4226
|
+
}
|
|
4227
|
+
/**
|
|
4228
|
+
* Record that the provider rejected an issued credential with permission denial.
|
|
4229
|
+
*/
|
|
4230
|
+
async function setSandboxEgressPermissionDeniedSignal(context, signal) {
|
|
4231
|
+
const ttlMs = Math.max(1, context.expiresAtMs - Date.now());
|
|
4232
|
+
const state = getStateAdapter();
|
|
4233
|
+
await state.connect();
|
|
4234
|
+
await state.set(permissionSignalKey(context.egressId, signal.grant.access), {
|
|
4235
|
+
...signal,
|
|
4236
|
+
createdAtMs: Date.now()
|
|
4237
|
+
}, ttlMs);
|
|
4238
|
+
}
|
|
4239
|
+
/** Remove pending auth/permission signals before or after a sandbox command. */
|
|
4240
|
+
async function clearSandboxEgressSignals(egressId) {
|
|
4241
|
+
if (!egressId) return;
|
|
4242
|
+
const state = getStateAdapter();
|
|
4243
|
+
await state.connect();
|
|
4244
|
+
await Promise.all([
|
|
4245
|
+
state.delete(authSignalKey(egressId, "read")),
|
|
4246
|
+
state.delete(authSignalKey(egressId, "write")),
|
|
4247
|
+
state.delete(permissionSignalKey(egressId, "read")),
|
|
4248
|
+
state.delete(permissionSignalKey(egressId, "write"))
|
|
4249
|
+
]);
|
|
4250
|
+
}
|
|
4251
|
+
/**
|
|
4252
|
+
* Consume the auth signal produced during a sandbox command, preferring writes.
|
|
4253
|
+
*/
|
|
4254
|
+
async function consumeSandboxEgressAuthRequiredSignal(egressId) {
|
|
4255
|
+
if (!egressId) return;
|
|
4256
|
+
const state = getStateAdapter();
|
|
4257
|
+
await state.connect();
|
|
4258
|
+
const [writeSignal, readSignal] = await Promise.all([state.get(authSignalKey(egressId, "write")), state.get(authSignalKey(egressId, "read"))]);
|
|
4259
|
+
const signal = parseSandboxEgressAuthRequiredSignal(writeSignal) ?? parseSandboxEgressAuthRequiredSignal(readSignal);
|
|
4260
|
+
await Promise.all([state.delete(authSignalKey(egressId, "read")), state.delete(authSignalKey(egressId, "write"))]);
|
|
4261
|
+
return signal;
|
|
4262
|
+
}
|
|
4263
|
+
/**
|
|
4264
|
+
* Consume the permission signal produced during a sandbox command, preferring writes.
|
|
4265
|
+
*/
|
|
4266
|
+
async function consumeSandboxEgressPermissionDeniedSignal(egressId) {
|
|
4267
|
+
if (!egressId) return;
|
|
4268
|
+
const state = getStateAdapter();
|
|
4269
|
+
await state.connect();
|
|
4270
|
+
const [writeSignal, readSignal] = await Promise.all([state.get(permissionSignalKey(egressId, "write")), state.get(permissionSignalKey(egressId, "read"))]);
|
|
4271
|
+
const signal = parseSandboxEgressPermissionDeniedSignal(writeSignal) ?? parseSandboxEgressPermissionDeniedSignal(readSignal);
|
|
4272
|
+
await Promise.all([state.delete(permissionSignalKey(egressId, "read")), state.delete(permissionSignalKey(egressId, "write"))]);
|
|
4273
|
+
return signal;
|
|
4274
|
+
}
|
|
4275
|
+
// src/chat/sandbox/egress/proxy.ts
|
|
4276
|
+
function jsonError$1(message, status) {
|
|
4277
|
+
return Response.json({ error: message }, { status });
|
|
4278
|
+
}
|
|
4279
|
+
/** Return log attributes that identify one sandbox egress request. */
|
|
4280
|
+
function sandboxEgressAttributes(input) {
|
|
4281
|
+
return {
|
|
4282
|
+
...input.egressId ? { "app.sandbox.egress_id": input.egressId } : void 0,
|
|
4283
|
+
...input.provider ? { "app.provider.name": input.provider } : void 0,
|
|
4284
|
+
...input.host ? { "server.address": input.host } : void 0,
|
|
4285
|
+
...input.method ? { "http.request.method": input.method } : void 0,
|
|
4286
|
+
...input.path ? { "url.path": input.path } : void 0,
|
|
4287
|
+
...input.status ? { "http.response.status_code": input.status } : void 0
|
|
4288
|
+
};
|
|
4289
|
+
}
|
|
4290
|
+
/** Hide the credential token in a sandbox egress proxy path before logging. */
|
|
4291
|
+
function redactSandboxEgressProxyPath(pathname) {
|
|
4292
|
+
if (pathname.startsWith(`/api/internal/sandbox-egress/`)) return `${SANDBOX_EGRESS_PROXY_PATH}/<token>`;
|
|
4293
|
+
return pathname;
|
|
4294
|
+
}
|
|
4295
|
+
/** Return log attributes for the proxy route and the upstream path. */
|
|
4296
|
+
function sandboxEgressRoutingAttributes(request, upstreamUrl) {
|
|
4297
|
+
const attributes = { "app.sandbox.egress.proxy_path": redactSandboxEgressProxyPath(new URL(request.url).pathname) };
|
|
4298
|
+
if (upstreamUrl) {
|
|
4299
|
+
attributes["app.sandbox.egress.upstream_path"] = upstreamUrl.pathname;
|
|
4300
|
+
const gitService = upstreamUrl.searchParams.get("service");
|
|
4301
|
+
if (upstreamUrl.hostname.toLowerCase() === "github.com" && (gitService === "git-upload-pack" || gitService === "git-receive-pack")) attributes["app.sandbox.egress.git_service"] = gitService;
|
|
4302
|
+
}
|
|
4303
|
+
return attributes;
|
|
4304
|
+
}
|
|
4305
|
+
/**
|
|
4306
|
+
* Name the Conversation and the accountable person from the verified token.
|
|
4307
|
+
* Each proxy request is a new HTTP request with no run log context, so the
|
|
4308
|
+
* proxy binds the same fields that the run binds.
|
|
4309
|
+
*/
|
|
4310
|
+
function sandboxEgressLogContext(context) {
|
|
4311
|
+
return {
|
|
4312
|
+
...context.conversationId ? { conversationId: context.conversationId } : void 0,
|
|
4313
|
+
...credentialLogContext(context.credentials)
|
|
4314
|
+
};
|
|
4315
|
+
}
|
|
4316
|
+
/** Continue inbound trace context only after sandbox egress verification succeeds. */
|
|
4317
|
+
function continueSandboxEgressTrace(request, upstreamHost, tracePropagation, credentialContext, callback) {
|
|
4318
|
+
const sentryTrace = request.headers.get("sentry-trace") ?? void 0;
|
|
4319
|
+
const baggage = request.headers.get("baggage") ?? void 0;
|
|
4320
|
+
const logContext = sandboxEgressLogContext(credentialContext);
|
|
4321
|
+
const run = () => withSpan("sandbox.egress", "http.server", logContext, async () => {
|
|
4322
|
+
setSentryUser(logContext.userId ? { id: logContext.userId } : void 0);
|
|
4323
|
+
return await callback();
|
|
4324
|
+
}, {
|
|
4325
|
+
"http.request.method": request.method,
|
|
4326
|
+
"url.path": redactSandboxEgressProxyPath(new URL(request.url).pathname)
|
|
4327
|
+
});
|
|
4328
|
+
if (!shouldPropagateSandboxEgressTrace(upstreamHost, tracePropagation) || !sentryTrace && !baggage) return run();
|
|
4329
|
+
return continueTrace({
|
|
4330
|
+
sentryTrace,
|
|
4331
|
+
baggage
|
|
4332
|
+
}, run);
|
|
4333
|
+
}
|
|
4334
|
+
/**
|
|
4335
|
+
* Run one sandbox egress request that its sandbox provider authenticated.
|
|
4336
|
+
*
|
|
4337
|
+
* This is the shared authorization boundary. It rejects upstream hosts that no
|
|
4338
|
+
* plugin owns, rejects signed credential contexts that do not match the
|
|
4339
|
+
* verified VM id, and only then asks the credentialed egress engine to issue
|
|
4340
|
+
* credentials and contact the upstream provider.
|
|
4341
|
+
*/
|
|
4342
|
+
async function executeSandboxEgressRequest(input, deps = {}) {
|
|
4343
|
+
const { request, upstreamUrl } = input;
|
|
4344
|
+
const activeEgressId = input.egressId;
|
|
4345
|
+
const provider = resolveSandboxEgressProviderForHost(upstreamUrl.hostname);
|
|
4346
|
+
if (!provider) {
|
|
4347
|
+
logWarn("sandbox.egress.provider.unresolved", {
|
|
4348
|
+
...sandboxEgressAttributes({
|
|
4349
|
+
egressId: activeEgressId,
|
|
4350
|
+
host: upstreamUrl.hostname,
|
|
4351
|
+
method: request.method,
|
|
4352
|
+
path: upstreamUrl.pathname,
|
|
4353
|
+
status: 403
|
|
4354
|
+
}),
|
|
4355
|
+
...sandboxEgressRoutingAttributes(request, upstreamUrl)
|
|
4356
|
+
});
|
|
4357
|
+
return jsonError$1("No provider owns forwarded host", 403);
|
|
4358
|
+
}
|
|
4359
|
+
const credentialContext = parseSandboxEgressCredentialToken(input.credentialToken);
|
|
4360
|
+
if (!credentialContext || credentialContext.egressId !== activeEgressId) {
|
|
4361
|
+
logWarn("sandbox.egress.credential_context.unauthorized", {
|
|
4362
|
+
...sandboxEgressAttributes({
|
|
4363
|
+
egressId: activeEgressId,
|
|
4364
|
+
host: upstreamUrl.hostname,
|
|
4365
|
+
method: request.method,
|
|
4366
|
+
path: upstreamUrl.pathname,
|
|
4367
|
+
provider,
|
|
4368
|
+
status: 403
|
|
4369
|
+
}),
|
|
4370
|
+
...sandboxEgressRoutingAttributes(request, upstreamUrl)
|
|
4371
|
+
});
|
|
4372
|
+
return jsonError$1("Sandbox egress credential context is not authorized", 403);
|
|
4373
|
+
}
|
|
4374
|
+
return await continueSandboxEgressTrace(request, upstreamUrl.hostname, deps.tracePropagation ?? {}, credentialContext, async () => await executeCredentialedEgressRequest({
|
|
4375
|
+
activeEgressId,
|
|
4376
|
+
credentialContext,
|
|
4377
|
+
deps,
|
|
4378
|
+
provider,
|
|
4379
|
+
request,
|
|
4380
|
+
upstreamUrl
|
|
4381
|
+
}));
|
|
4382
|
+
}
|
|
4383
|
+
// src/chat/sandbox/egress/vercel.ts
|
|
4384
|
+
const OIDC_TOKEN_HEADER = "vercel-sandbox-oidc-token";
|
|
4385
|
+
const FORWARDED_HOST_HEADER = "vercel-forwarded-host";
|
|
4386
|
+
const FORWARDED_SCHEME_HEADER = "vercel-forwarded-scheme";
|
|
4387
|
+
const FORWARDED_PORT_HEADER = "vercel-forwarded-port";
|
|
4388
|
+
const FORWARDED_PATH_HEADER = "vercel-forwarded-path";
|
|
4389
|
+
function jsonError(message, status) {
|
|
4390
|
+
return Response.json({ error: message }, { status });
|
|
4391
|
+
}
|
|
4392
|
+
function forwardUrl(credentialToken) {
|
|
4393
|
+
const baseUrl = resolveBaseUrl();
|
|
4394
|
+
if (!baseUrl) throw new Error("Cannot determine base URL for sandbox credential egress (set JUNIOR_BASE_URL or deploy to Vercel)");
|
|
4395
|
+
return new URL(`${SANDBOX_EGRESS_PROXY_PATH}/${credentialToken}`, baseUrl).toString();
|
|
4396
|
+
}
|
|
4397
|
+
/**
|
|
4398
|
+
* Turn Junior's network policy into Vercel Sandbox network rules.
|
|
4399
|
+
*
|
|
4400
|
+
* Every other domain stays open through the `*` rule. Credentialed domains
|
|
4401
|
+
* forward to Junior's egress route, and trace domains get a header transform.
|
|
4402
|
+
*/
|
|
4403
|
+
function toVercelNetworkPolicy(policy) {
|
|
4404
|
+
if (policy === "allow-all") return policy;
|
|
4405
|
+
const rules = /* @__PURE__ */ new Map();
|
|
4406
|
+
const credentialEgress = policy.credentialEgress;
|
|
4407
|
+
if (credentialEgress && credentialEgress.domains.length > 0) {
|
|
4408
|
+
const forwardURL = forwardUrl(credentialEgress.credentialToken);
|
|
4409
|
+
for (const domain of credentialEgress.domains) rules.set(domain, [{ forwardURL }]);
|
|
4410
|
+
}
|
|
4411
|
+
const traceHeaders = policy.traceHeaders;
|
|
4412
|
+
if (traceHeaders) for (const domain of traceHeaders.domains) rules.set(domain, [{ transform: [{ headers: traceHeaders.headers }] }]);
|
|
4413
|
+
return { allow: {
|
|
4414
|
+
"*": [],
|
|
4415
|
+
...Object.fromEntries([...rules.entries()].sort(([left], [right]) => left.localeCompare(right)))
|
|
4416
|
+
} };
|
|
4417
|
+
}
|
|
4418
|
+
/** Read the signed credential token from the forward URL path. */
|
|
4419
|
+
function credentialTokenFromRequest(request) {
|
|
4420
|
+
const pathname = new URL(request.url).pathname;
|
|
4421
|
+
const prefix = `${SANDBOX_EGRESS_PROXY_PATH}/`;
|
|
4422
|
+
if (!pathname.startsWith(prefix)) return;
|
|
4423
|
+
const token = pathname.slice(prefix.length).split("/")[0];
|
|
4424
|
+
if (!token) return;
|
|
4425
|
+
try {
|
|
4426
|
+
return decodeURIComponent(token);
|
|
4427
|
+
} catch {
|
|
4428
|
+
return;
|
|
4429
|
+
}
|
|
4430
|
+
}
|
|
4431
|
+
function normalizeHost(value) {
|
|
4432
|
+
const trimmed = value.trim().toLowerCase();
|
|
4433
|
+
if (!trimmed || trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes(":")) return;
|
|
4434
|
+
return trimmed.replace(/\.$/, "");
|
|
4435
|
+
}
|
|
4436
|
+
function normalizeScheme(value) {
|
|
4437
|
+
return value.trim().toLowerCase() === "https" ? "https" : void 0;
|
|
4438
|
+
}
|
|
4439
|
+
function normalizePort(value) {
|
|
4440
|
+
if (!value) return;
|
|
4441
|
+
const trimmed = value.trim();
|
|
4442
|
+
if (!/^\d{1,5}$/.test(trimmed)) return;
|
|
4443
|
+
const port = Number.parseInt(trimmed, 10);
|
|
4444
|
+
return port >= 1 && port <= 65535 ? trimmed : void 0;
|
|
4445
|
+
}
|
|
4446
|
+
function sandboxIdFromPayload(payload) {
|
|
4447
|
+
return typeof payload.sandbox_id === "string" ? payload.sandbox_id : void 0;
|
|
4448
|
+
}
|
|
4449
|
+
function normalizedForwardedPath(path) {
|
|
4450
|
+
if (!path.startsWith("/") || path.startsWith("//") || path.includes("#") || /[\r\n]/.test(path)) return {
|
|
4451
|
+
ok: false,
|
|
4452
|
+
error: "Invalid forwarded path"
|
|
4453
|
+
};
|
|
4454
|
+
try {
|
|
4455
|
+
const url = new URL(path, "https://sandbox-forwarded.local");
|
|
4456
|
+
return {
|
|
4457
|
+
ok: true,
|
|
4458
|
+
path: `${url.pathname}${url.search}`
|
|
4459
|
+
};
|
|
4460
|
+
} catch {
|
|
4461
|
+
return {
|
|
4462
|
+
ok: false,
|
|
4463
|
+
error: "Invalid forwarded path"
|
|
4464
|
+
};
|
|
4465
|
+
}
|
|
4466
|
+
}
|
|
4467
|
+
function upstreamPath(request) {
|
|
4468
|
+
const forwardedPath = request.headers.get(FORWARDED_PATH_HEADER);
|
|
4469
|
+
if (!forwardedPath?.trim()) return {
|
|
4470
|
+
ok: false,
|
|
4471
|
+
error: "Missing forwarded path"
|
|
4472
|
+
};
|
|
4473
|
+
return normalizedForwardedPath(forwardedPath.trim());
|
|
4474
|
+
}
|
|
4475
|
+
/**
|
|
4476
|
+
* Rebuild the HTTPS upstream URL from Vercel forwarding headers.
|
|
4477
|
+
*
|
|
4478
|
+
* This is the proxy's wire-format boundary for forwarded requests: host, path,
|
|
4479
|
+
* scheme, and port must normalize before the request can reach provider egress.
|
|
4480
|
+
*/
|
|
4481
|
+
function buildUpstreamUrl(request) {
|
|
4482
|
+
const forwardedHost = request.headers.get(FORWARDED_HOST_HEADER);
|
|
4483
|
+
if (!forwardedHost?.trim()) return {
|
|
4484
|
+
ok: false,
|
|
4485
|
+
error: "Missing forwarded host"
|
|
4486
|
+
};
|
|
4487
|
+
const host = normalizeHost(forwardedHost);
|
|
4488
|
+
if (!host) return {
|
|
4489
|
+
ok: false,
|
|
4490
|
+
error: "Invalid forwarded host"
|
|
4491
|
+
};
|
|
4492
|
+
const forwardedScheme = request.headers.get(FORWARDED_SCHEME_HEADER);
|
|
4493
|
+
if (!forwardedScheme?.trim()) return {
|
|
4494
|
+
ok: false,
|
|
4495
|
+
error: "Missing forwarded scheme"
|
|
4496
|
+
};
|
|
4497
|
+
const scheme = normalizeScheme(forwardedScheme);
|
|
4498
|
+
if (!scheme) return {
|
|
4499
|
+
ok: false,
|
|
4500
|
+
error: "Forwarded scheme must be https"
|
|
4501
|
+
};
|
|
4502
|
+
const forwardedPort = request.headers.get(FORWARDED_PORT_HEADER);
|
|
4503
|
+
const port = normalizePort(forwardedPort);
|
|
4504
|
+
if (forwardedPort && !port) return {
|
|
4505
|
+
ok: false,
|
|
4506
|
+
error: "Invalid forwarded port"
|
|
4507
|
+
};
|
|
4508
|
+
const path = upstreamPath(request);
|
|
4509
|
+
if (!path.ok) return {
|
|
4510
|
+
ok: false,
|
|
4511
|
+
error: path.error
|
|
4512
|
+
};
|
|
4513
|
+
try {
|
|
4514
|
+
return {
|
|
4515
|
+
ok: true,
|
|
4516
|
+
url: new URL(`${scheme}://${host}${port ? `:${port}` : ""}${path.path}`)
|
|
4517
|
+
};
|
|
4518
|
+
} catch {
|
|
4519
|
+
return {
|
|
4520
|
+
ok: false,
|
|
4521
|
+
error: "Invalid forwarded URL"
|
|
4522
|
+
};
|
|
4523
|
+
}
|
|
4524
|
+
}
|
|
4525
|
+
/**
|
|
4526
|
+
* Return whether a request carries the forwarding headers expected from Vercel.
|
|
4527
|
+
*
|
|
4528
|
+
* This is only a cheap classifier for routing into the proxy handler. Real
|
|
4529
|
+
* authentication happens in `proxyVercelSandboxEgressRequest`, which verifies
|
|
4530
|
+
* the OIDC token, and in `executeSandboxEgressRequest`, which verifies the
|
|
4531
|
+
* signed Junior credential context.
|
|
4532
|
+
*/
|
|
4533
|
+
function isVercelSandboxEgressRequest(request) {
|
|
4534
|
+
return Boolean(request.headers.get(OIDC_TOKEN_HEADER)?.trim() && request.headers.get(FORWARDED_HOST_HEADER)?.trim() && request.headers.get(FORWARDED_SCHEME_HEADER)?.trim());
|
|
4535
|
+
}
|
|
4536
|
+
/**
|
|
4537
|
+
* Handle one sandbox egress request that Vercel's network layer forwarded.
|
|
4538
|
+
*
|
|
4539
|
+
* This is the Vercel side of the proxy boundary. It rejects requests without a
|
|
4540
|
+
* valid Vercel Sandbox OIDC token and rejects invalid forwarded URLs. Then it
|
|
4541
|
+
* hands the verified VM id, upstream URL, and credential token to the shared
|
|
4542
|
+
* `executeSandboxEgressRequest`.
|
|
4543
|
+
*/
|
|
4544
|
+
async function proxyVercelSandboxEgressRequest(request, deps = {}) {
|
|
4545
|
+
const oidcToken = request.headers.get(OIDC_TOKEN_HEADER)?.trim();
|
|
4546
|
+
if (!oidcToken) return jsonError("Missing Vercel Sandbox OIDC token", 401);
|
|
4547
|
+
let oidcPayload;
|
|
4548
|
+
try {
|
|
4549
|
+
oidcPayload = await (deps.verifyOidc ?? verifyVercelSandboxOidcToken)(oidcToken);
|
|
4550
|
+
} catch (error) {
|
|
4551
|
+
logWarn("sandbox.egress.oidc_verification.failed", { "app.sandbox.oidc_error": error instanceof Error ? error.message : String(error) });
|
|
4552
|
+
return jsonError("Invalid Vercel Sandbox OIDC token", 401);
|
|
4553
|
+
}
|
|
4554
|
+
const activeEgressId = sandboxIdFromPayload(oidcPayload);
|
|
4555
|
+
if (!activeEgressId) {
|
|
4556
|
+
logWarn("sandbox.egress.oidc_session.missing", {
|
|
4557
|
+
"http.request.method": request.method,
|
|
4558
|
+
"url.path": redactSandboxEgressProxyPath(new URL(request.url).pathname)
|
|
4559
|
+
});
|
|
4560
|
+
return jsonError("Vercel Sandbox OIDC token did not include sandbox_id", 401);
|
|
4561
|
+
}
|
|
4562
|
+
const upstreamResult = buildUpstreamUrl(request);
|
|
4563
|
+
if (!upstreamResult.ok) {
|
|
4564
|
+
logWarn("sandbox.egress.upstream_url.invalid", {
|
|
4565
|
+
...sandboxEgressAttributes({
|
|
4566
|
+
egressId: activeEgressId,
|
|
4567
|
+
method: request.method,
|
|
4568
|
+
path: redactSandboxEgressProxyPath(new URL(request.url).pathname),
|
|
4569
|
+
status: 400
|
|
4570
|
+
}),
|
|
4571
|
+
...sandboxEgressRoutingAttributes(request)
|
|
4572
|
+
});
|
|
4573
|
+
return jsonError(upstreamResult.error, 400);
|
|
4574
|
+
}
|
|
4575
|
+
return await executeSandboxEgressRequest({
|
|
4576
|
+
request,
|
|
4577
|
+
egressId: activeEgressId,
|
|
4578
|
+
upstreamUrl: upstreamResult.url,
|
|
4579
|
+
credentialToken: credentialTokenFromRequest(request)
|
|
4580
|
+
}, deps);
|
|
4581
|
+
}
|
|
4582
|
+
// src/chat/sandbox/http-error-details.ts
|
|
4583
|
+
const DEFAULT_PREVIEW_LIMIT = 512;
|
|
4584
|
+
function toTrimmedString(value, maxChars) {
|
|
4585
|
+
if (typeof value !== "string") return;
|
|
4586
|
+
const normalized = value.trim();
|
|
4587
|
+
if (!normalized) return;
|
|
4588
|
+
return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}…` : normalized;
|
|
4589
|
+
}
|
|
4590
|
+
/** Extract structured HTTP error metadata (status, headers, body preview) for logging. */
|
|
4591
|
+
function extractHttpErrorDetails(error, options = {}) {
|
|
4592
|
+
const prefix = options.attributePrefix ?? "app.http_error";
|
|
4593
|
+
const previewLimit = options.previewLimit ?? DEFAULT_PREVIEW_LIMIT;
|
|
4594
|
+
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
4595
|
+
const err = error ?? {};
|
|
4596
|
+
const attributes = {
|
|
4597
|
+
"error.type": normalizedError.name || "Error",
|
|
4598
|
+
"exception.message": toTrimmedString(normalizedError.message, previewLimit) ?? "HTTP error"
|
|
4599
|
+
};
|
|
4600
|
+
const response = err.response;
|
|
4601
|
+
const statusCode = typeof response?.status === "number" ? response.status : void 0;
|
|
4602
|
+
const statusText = toTrimmedString(response?.statusText, previewLimit);
|
|
4603
|
+
const responseUrl = toTrimmedString(response?.url, previewLimit);
|
|
4604
|
+
const responseText = toTrimmedString(err.text, previewLimit);
|
|
4605
|
+
const responseJson = toTrimmedString(err.json && typeof err.json === "object" ? JSON.stringify(err.json) : void 0, previewLimit);
|
|
4606
|
+
const contentType = toTrimmedString(response?.headers?.get?.("content-type"), previewLimit);
|
|
4607
|
+
const requestIdHeader = toTrimmedString(response?.headers?.get?.("x-request-id"), previewLimit);
|
|
4608
|
+
const vercelIdHeader = toTrimmedString(response?.headers?.get?.("x-vercel-id"), previewLimit);
|
|
4609
|
+
const requestId = requestIdHeader ?? vercelIdHeader;
|
|
4610
|
+
if (statusCode !== void 0) attributes["http.response.status_code"] = statusCode;
|
|
4611
|
+
if (statusText) attributes[`${prefix}.status_text`] = statusText;
|
|
4612
|
+
if (responseUrl) attributes["url.full"] = responseUrl;
|
|
4613
|
+
if (contentType) attributes["http.response.header.content-type"] = [contentType];
|
|
4614
|
+
if (requestIdHeader) attributes["http.response.header.x-request-id"] = [requestIdHeader];
|
|
4615
|
+
if (vercelIdHeader) attributes["http.response.header.x-vercel-id"] = [vercelIdHeader];
|
|
4616
|
+
if (responseText) {
|
|
4617
|
+
attributes[`${prefix}.response_text_preview`] = responseText;
|
|
4618
|
+
attributes[`${prefix}.response_text_length`] = responseText.length;
|
|
4619
|
+
}
|
|
4620
|
+
if (responseJson) attributes[`${prefix}.response_json_preview`] = responseJson;
|
|
4621
|
+
const summaryParts = [];
|
|
4622
|
+
if (statusCode !== void 0) summaryParts.push(`status=${statusCode}`);
|
|
4623
|
+
if (statusText) summaryParts.push(`statusText=${statusText}`);
|
|
4624
|
+
if (responseUrl) summaryParts.push(`url=${responseUrl}`);
|
|
4625
|
+
if (requestId) summaryParts.push(`requestId=${requestId}`);
|
|
4626
|
+
for (const field of options.extraFields ?? []) {
|
|
4627
|
+
const value = toTrimmedString(err[field.sourceKey], previewLimit);
|
|
4628
|
+
if (!value) continue;
|
|
4629
|
+
attributes[`${prefix}.${field.attributeKey}`] = value;
|
|
4630
|
+
summaryParts.push(`${field.summaryKey ?? field.attributeKey}=${value}`);
|
|
4631
|
+
}
|
|
4632
|
+
if (responseJson) summaryParts.push(`json=${responseJson}`);
|
|
4633
|
+
else if (responseText) summaryParts.push(`text=${responseText}`);
|
|
4634
|
+
const searchableText = `${normalizedError.message} ${responseText ?? ""} ${responseJson ?? ""}`.toLowerCase();
|
|
4635
|
+
return {
|
|
4636
|
+
attributes,
|
|
4637
|
+
summary: summaryParts.join(", "),
|
|
4638
|
+
searchableText
|
|
4639
|
+
};
|
|
4640
|
+
}
|
|
4641
|
+
// src/chat/sandbox/snapshot/not-ready-error.ts
|
|
4642
|
+
/** Report that a Workspace snapshot is not ready to use. */
|
|
4643
|
+
var WorkspaceSnapshotNotReadyError = class extends Error {
|
|
4644
|
+
workspaceName;
|
|
4645
|
+
constructor(workspaceName) {
|
|
4646
|
+
super(`Workspace ${workspaceName} snapshot is not ready`);
|
|
4647
|
+
this.name = "WorkspaceSnapshotNotReadyError";
|
|
4648
|
+
this.workspaceName = workspaceName;
|
|
4649
|
+
}
|
|
4650
|
+
};
|
|
4651
|
+
/** Return WorkspaceSnapshotNotReadyError from this error or its causes. */
|
|
4652
|
+
function getWorkspaceSnapshotNotReadyError(error) {
|
|
4653
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4654
|
+
let current = error;
|
|
4655
|
+
while (current instanceof Error && !seen.has(current)) {
|
|
4656
|
+
if (current instanceof WorkspaceSnapshotNotReadyError) return current;
|
|
4657
|
+
seen.add(current);
|
|
4658
|
+
current = current.cause;
|
|
4659
|
+
}
|
|
4660
|
+
}
|
|
4661
|
+
/** True when this error or a cause is WorkspaceSnapshotNotReadyError. */
|
|
4662
|
+
function isWorkspaceSnapshotNotReadyError(error) {
|
|
4663
|
+
return getWorkspaceSnapshotNotReadyError(error) !== void 0;
|
|
4664
|
+
}
|
|
4665
|
+
/** User message when a Workspace sandbox is still preparing. */
|
|
4666
|
+
function workspaceSnapshotNotReadyUserMessage(error) {
|
|
4667
|
+
return `The ${error.workspaceName} workspace is still preparing its sandbox. Wait for that preparation to finish, then try again.`;
|
|
4668
|
+
}
|
|
4669
|
+
// src/chat/sandbox/errors.ts
|
|
4670
|
+
const SANDBOX_ERROR_FIELDS = [{
|
|
4671
|
+
sourceKey: "sandboxId",
|
|
4672
|
+
attributeKey: "sandbox_id",
|
|
4673
|
+
summaryKey: "sandboxId"
|
|
4674
|
+
}];
|
|
4675
|
+
/** Extract HTTP error details from a sandbox error for span data and checks. */
|
|
4676
|
+
function getSandboxErrorDetails(error) {
|
|
4677
|
+
return extractHttpErrorDetails(error, {
|
|
4678
|
+
attributePrefix: "app.sandbox.api_error",
|
|
4679
|
+
extraFields: [...SANDBOX_ERROR_FIELDS]
|
|
4680
|
+
});
|
|
4681
|
+
}
|
|
4682
|
+
/** Return true when any error in the `cause` chain matches the predicate. */
|
|
4683
|
+
function findInErrorChain(error, predicate) {
|
|
4684
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4685
|
+
let current = error;
|
|
4686
|
+
while (current && !seen.has(current)) {
|
|
4687
|
+
if (predicate(current)) return true;
|
|
4688
|
+
seen.add(current);
|
|
4689
|
+
current = typeof current === "object" ? current.cause : void 0;
|
|
4690
|
+
}
|
|
4691
|
+
return false;
|
|
4692
|
+
}
|
|
4693
|
+
function getFirstErrorMessage(error) {
|
|
4694
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4695
|
+
let current = error;
|
|
4696
|
+
while (current && !seen.has(current)) {
|
|
4697
|
+
if (current instanceof Error) {
|
|
4698
|
+
const message = current.message.trim();
|
|
4699
|
+
if (message) return message;
|
|
4700
|
+
}
|
|
4701
|
+
seen.add(current);
|
|
4702
|
+
current = typeof current === "object" ? current.cause : void 0;
|
|
4703
|
+
}
|
|
4704
|
+
}
|
|
4705
|
+
/** Detect sandbox filesystem operations that can safely ignore existing directories. */
|
|
4706
|
+
function isAlreadyExistsError(error) {
|
|
4707
|
+
const details = getSandboxErrorDetails(error);
|
|
4708
|
+
return details.searchableText.includes("already exists") || details.searchableText.includes("file exists") || details.searchableText.includes("eexist");
|
|
4709
|
+
}
|
|
4710
|
+
/** Detect cancellation so a durable builder stays available for check-in. */
|
|
4711
|
+
function isAbortError(error) {
|
|
4712
|
+
return findInErrorChain(error, (candidate) => {
|
|
4713
|
+
if (typeof DOMException !== "undefined" && candidate instanceof DOMException) return candidate.name === "AbortError";
|
|
4714
|
+
return candidate instanceof Error && candidate.name === "AbortError";
|
|
4715
|
+
});
|
|
4716
|
+
}
|
|
4717
|
+
/** Wrap setup failures. Keep WorkspaceSnapshotNotReadyError as-is. */
|
|
4718
|
+
function wrapSandboxSetupError(error) {
|
|
4719
|
+
const notReady = getWorkspaceSnapshotNotReadyError(error);
|
|
4720
|
+
if (notReady) return notReady;
|
|
4721
|
+
try {
|
|
4722
|
+
const details = getSandboxErrorDetails(error);
|
|
4723
|
+
if (details.summary) return new Error(`sandbox setup failed (${details.summary})`, { cause: error });
|
|
4724
|
+
} catch {}
|
|
4725
|
+
let causeMessage;
|
|
4726
|
+
try {
|
|
4727
|
+
causeMessage = getFirstErrorMessage(error);
|
|
4728
|
+
} catch (cause) {
|
|
4729
|
+
causeMessage = cause instanceof Error ? cause.message : void 0;
|
|
4730
|
+
}
|
|
4731
|
+
if (causeMessage && causeMessage.trim() && causeMessage !== "sandbox setup failed") {
|
|
4732
|
+
const oneLine = causeMessage.replace(/\s+/g, " ").trim();
|
|
4733
|
+
return new Error(`sandbox setup failed (${oneLine})`, { cause: error });
|
|
4734
|
+
}
|
|
4735
|
+
return new Error("sandbox setup failed", { cause: error });
|
|
4736
|
+
}
|
|
4737
|
+
/** Record span data and throw one stable sandbox operation error. */
|
|
4738
|
+
function throwSandboxOperationError(action, error, includeMissingPath = false) {
|
|
4739
|
+
const details = getSandboxErrorDetails(error);
|
|
4740
|
+
setSpanAttributes({
|
|
4741
|
+
...details.attributes,
|
|
4742
|
+
...includeMissingPath ? { "app.sandbox.api_error.missing_path": details.searchableText.includes("no such file") || details.searchableText.includes("enoent") } : void 0,
|
|
4743
|
+
"app.sandbox.success": false
|
|
4744
|
+
});
|
|
4745
|
+
throw new Error(details.summary ? `${action} failed (${details.summary})` : `${action} failed`, { cause: error });
|
|
4746
|
+
}
|
|
4747
|
+
// src/chat/sandbox/vercel/errors.ts
|
|
4748
|
+
function isRecord$1(value) {
|
|
4749
|
+
return typeof value === "object" && value !== null;
|
|
4750
|
+
}
|
|
4751
|
+
function searchableText(error) {
|
|
4752
|
+
const details = getSandboxErrorDetails(error);
|
|
4753
|
+
return `${details.searchableText} ${details.summary}`.toLowerCase();
|
|
4754
|
+
}
|
|
4755
|
+
function isInvalidSandboxSessionError(error) {
|
|
4756
|
+
if (!isRecord$1(error)) return false;
|
|
4757
|
+
if (isRecord$1(error.json) && error.json.invalidToken === true) return true;
|
|
4758
|
+
const response = error.response;
|
|
4759
|
+
return isRecord$1(response) && response.status === 403 && typeof response.url === "string" && response.url.includes("/sandboxes/sessions");
|
|
4760
|
+
}
|
|
4761
|
+
function isUnavailable(error) {
|
|
4762
|
+
return findInErrorChain(error, (candidate) => {
|
|
4763
|
+
if (isInvalidSandboxSessionError(candidate)) return true;
|
|
4764
|
+
const searchable = searchableText(candidate);
|
|
4765
|
+
return searchable.includes("sandbox_stopped") || searchable.includes("status=410") || searchable.includes("status code 410") || searchable.includes("no longer available") || searchable.includes("stream was closed and is not accepting commands");
|
|
4766
|
+
});
|
|
4767
|
+
}
|
|
4768
|
+
function isMissing(error) {
|
|
4769
|
+
return findInErrorChain(error, (candidate) => {
|
|
4770
|
+
const searchable = searchableText(candidate);
|
|
4771
|
+
return searchable.includes("status=404") || searchable.includes("status code 404") || searchable.includes("snapshot_not_found");
|
|
4772
|
+
});
|
|
4773
|
+
}
|
|
4774
|
+
function isSnapshotMissing(error) {
|
|
4775
|
+
const searchable = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
4776
|
+
return searchable.includes("snapshot") && (searchable.includes("not found") || searchable.includes("unknown") || searchable.includes("404"));
|
|
4777
|
+
}
|
|
4778
|
+
function isSnapshotting(error) {
|
|
4779
|
+
return findInErrorChain(error, (candidate) => {
|
|
4780
|
+
const searchable = searchableText(candidate);
|
|
4781
|
+
return searchable.includes("sandbox_snapshotting") || searchable.includes("creating a snapshot") || searchable.includes("stopped shortly");
|
|
4782
|
+
});
|
|
4783
|
+
}
|
|
4784
|
+
function isTransient(error) {
|
|
4785
|
+
return findInErrorChain(error, (candidate) => {
|
|
4786
|
+
const searchable = searchableText(candidate);
|
|
4787
|
+
if (searchable.includes("internal_server_error") || searchable.includes("status=500") || searchable.includes("status code 500") || searchable.includes("status=502") || searchable.includes("status code 502") || searchable.includes("status=503") || searchable.includes("status code 503") || searchable.includes("status=504") || searchable.includes("status code 504")) return true;
|
|
4788
|
+
if (!isRecord$1(candidate)) return false;
|
|
4789
|
+
const response = candidate.response;
|
|
4790
|
+
return isRecord$1(response) && typeof response.status === "number" && response.status >= 500 && response.status < 600;
|
|
4791
|
+
});
|
|
4792
|
+
}
|
|
4793
|
+
function isCommandStreamInterrupted(error) {
|
|
4794
|
+
return findInErrorChain(error, (candidate) => {
|
|
4795
|
+
if (!(candidate instanceof Error)) return false;
|
|
4796
|
+
return candidate.name === "StreamError" && candidate.message.toLowerCase().includes("stream ended before command finished");
|
|
4797
|
+
});
|
|
4798
|
+
}
|
|
4799
|
+
/** Map Vercel Sandbox API and SDK errors to the shared sandbox error cases. */
|
|
4800
|
+
const vercelSandboxErrors = {
|
|
4801
|
+
isUnavailable,
|
|
4802
|
+
isMissing,
|
|
4803
|
+
isSnapshotMissing,
|
|
4804
|
+
isSnapshotting,
|
|
4805
|
+
isTransient,
|
|
4806
|
+
isCommandStreamInterrupted
|
|
4807
|
+
};
|
|
4808
|
+
// src/chat/sandbox/vercel/provider.ts
|
|
4809
|
+
function credentialOptions() {
|
|
4810
|
+
return getVercelSandboxCredentials() ?? {};
|
|
4811
|
+
}
|
|
4812
|
+
/** Bind one abort signal to every API request made by a Sandbox client. */
|
|
4813
|
+
function scopedFetchOptions(signal) {
|
|
4814
|
+
if (!signal) return {};
|
|
4815
|
+
return { fetch: (input, init) => {
|
|
4816
|
+
const requestSignal = init?.signal ?? (input instanceof Request ? input.signal : void 0);
|
|
4817
|
+
return globalThis.fetch(input, {
|
|
4818
|
+
...init,
|
|
4819
|
+
signal: requestSignal ? AbortSignal.any([requestSignal, signal]) : signal
|
|
4820
|
+
});
|
|
4821
|
+
} };
|
|
4822
|
+
}
|
|
4823
|
+
/** Pin Junior's sandbox contract to one Vercel session without SDK replay. */
|
|
4824
|
+
function adaptSession(sandbox, options) {
|
|
4825
|
+
const session = sandbox.currentSession();
|
|
4826
|
+
const fileSystem = new FileSystem(session);
|
|
4827
|
+
const run = async (operation) => {
|
|
4828
|
+
try {
|
|
4829
|
+
return await operation();
|
|
4830
|
+
} catch (error) {
|
|
4831
|
+
if (vercelSandboxErrors.isUnavailable(error)) options?.onUnavailable?.(session.sessionId);
|
|
4832
|
+
throw error;
|
|
4833
|
+
}
|
|
4834
|
+
};
|
|
4835
|
+
return {
|
|
4836
|
+
sandboxId: sandbox.name,
|
|
4837
|
+
sessionId: session.sessionId,
|
|
4838
|
+
fs: {
|
|
4839
|
+
readFile: async (filePath, readOptions) => await run(async () => await fileSystem.readFile(filePath, readOptions)),
|
|
4840
|
+
writeFile: async (filePath, content, writeOptions) => await run(async () => await fileSystem.writeFile(filePath, content, writeOptions)),
|
|
4841
|
+
readdir: async (filePath, readOptions) => await run(async () => await fileSystem.readdir(filePath, readOptions)),
|
|
4842
|
+
stat: async (filePath, statOptions) => await run(async () => await fileSystem.stat(filePath, statOptions))
|
|
4843
|
+
},
|
|
4844
|
+
extendTimeout(duration) {
|
|
4845
|
+
return run(async () => await session.extendTimeout(duration));
|
|
4846
|
+
},
|
|
4847
|
+
mkDir(path) {
|
|
4848
|
+
return run(async () => await session.mkDir(path));
|
|
4849
|
+
},
|
|
4850
|
+
readFileToBuffer(input) {
|
|
4851
|
+
return run(async () => await session.readFileToBuffer(input));
|
|
4852
|
+
},
|
|
4853
|
+
async runCommand(input) {
|
|
4854
|
+
const result = await run(async () => await session.runCommand(input));
|
|
4855
|
+
const [stdout, stderr] = await Promise.all([run(async () => await result.stdout()), run(async () => await result.stderr())]);
|
|
4856
|
+
return {
|
|
4857
|
+
exitCode: result.exitCode,
|
|
4858
|
+
stdout,
|
|
4859
|
+
stderr
|
|
4860
|
+
};
|
|
4861
|
+
},
|
|
4862
|
+
snapshot(snapshotOptions) {
|
|
4863
|
+
return run(async () => {
|
|
4864
|
+
return { snapshotId: (await session.snapshot(snapshotOptions)).snapshotId };
|
|
4865
|
+
});
|
|
4866
|
+
},
|
|
4867
|
+
stop() {
|
|
4868
|
+
return run(async () => await session.stop());
|
|
4869
|
+
},
|
|
4870
|
+
update(params) {
|
|
4871
|
+
return run(async () => await session.update(params.networkPolicy ? { networkPolicy: toVercelNetworkPolicy(params.networkPolicy) } : {}));
|
|
4872
|
+
},
|
|
4873
|
+
writeFiles(files) {
|
|
4874
|
+
return run(async () => await session.writeFiles(files));
|
|
4875
|
+
}
|
|
4876
|
+
};
|
|
4877
|
+
}
|
|
4878
|
+
/** Create a Sandbox and return a session pinned to its first VM session. */
|
|
4879
|
+
async function createSandboxSession(input) {
|
|
4880
|
+
const shared = {
|
|
4881
|
+
timeout: input.timeoutMs,
|
|
4882
|
+
...input.name ? { name: input.name } : void 0,
|
|
4883
|
+
...input.persistent !== void 0 ? { persistent: input.persistent } : void 0,
|
|
4884
|
+
...input.networkPolicy ? { networkPolicy: toVercelNetworkPolicy(input.networkPolicy) } : void 0,
|
|
4885
|
+
...input.resources ? { resources: input.resources } : void 0,
|
|
4886
|
+
...input.signal ? { signal: input.signal } : void 0,
|
|
4887
|
+
...credentialOptions(),
|
|
4888
|
+
...scopedFetchOptions(input.scopeSignal)
|
|
4889
|
+
};
|
|
4890
|
+
return adaptSession(await Sandbox.create(input.snapshotId ? {
|
|
4891
|
+
...shared,
|
|
4892
|
+
source: {
|
|
4893
|
+
type: "snapshot",
|
|
4894
|
+
snapshotId: input.snapshotId
|
|
4895
|
+
}
|
|
4896
|
+
} : {
|
|
4897
|
+
...shared,
|
|
4898
|
+
...input.runtime ? { runtime: input.runtime } : void 0
|
|
4899
|
+
}), input);
|
|
4900
|
+
}
|
|
4901
|
+
/** Resume a named Sandbox and return a session pinned to its VM session. */
|
|
4902
|
+
async function getSandboxSession(input) {
|
|
4903
|
+
return adaptSession(await Sandbox.get({
|
|
4904
|
+
name: input.name,
|
|
4905
|
+
resume: true,
|
|
4906
|
+
...credentialOptions(),
|
|
4907
|
+
...scopedFetchOptions(input.scopeSignal)
|
|
4908
|
+
}), input);
|
|
4909
|
+
}
|
|
4910
|
+
/** Create the named, persistent Sandbox that owns one Workspace snapshot build. */
|
|
4911
|
+
async function createWorkspaceSnapshotBuilder(input) {
|
|
4912
|
+
await Sandbox.create({
|
|
4913
|
+
name: input.name,
|
|
4914
|
+
persistent: true,
|
|
4915
|
+
timeout: input.timeoutMs,
|
|
4916
|
+
runtime: input.runtime,
|
|
4917
|
+
signal: input.signal,
|
|
4918
|
+
...credentialOptions(),
|
|
4919
|
+
...input.resources ? { resources: input.resources } : void 0
|
|
4920
|
+
});
|
|
4921
|
+
}
|
|
2974
4922
|
/** Resume the named Sandbox that owns one Workspace snapshot build. */
|
|
2975
4923
|
async function getWorkspaceSnapshotBuilder(name, signal) {
|
|
2976
|
-
const
|
|
2977
|
-
return await Sandbox.get({
|
|
4924
|
+
const sandbox = await Sandbox.get({
|
|
2978
4925
|
name,
|
|
2979
4926
|
resume: true,
|
|
2980
4927
|
signal,
|
|
2981
|
-
...
|
|
4928
|
+
...credentialOptions()
|
|
2982
4929
|
});
|
|
4930
|
+
return {
|
|
4931
|
+
session: () => adaptSession(sandbox),
|
|
4932
|
+
async startDetachedCommand(input) {
|
|
4933
|
+
return (await sandbox.runCommand({
|
|
4934
|
+
...input,
|
|
4935
|
+
detached: true
|
|
4936
|
+
})).cmdId;
|
|
4937
|
+
},
|
|
4938
|
+
getCommand: (commandId, options) => sandbox.getCommand(commandId, options),
|
|
4939
|
+
async snapshot(options) {
|
|
4940
|
+
const snapshot = await sandbox.snapshot(options);
|
|
4941
|
+
return {
|
|
4942
|
+
snapshotId: snapshot.snapshotId,
|
|
4943
|
+
...typeof snapshot.sizeBytes === "number" ? { sizeBytes: snapshot.sizeBytes } : void 0
|
|
4944
|
+
};
|
|
4945
|
+
}
|
|
4946
|
+
};
|
|
2983
4947
|
}
|
|
2984
4948
|
/** Permanently delete named Workspace snapshot builders without resuming them. */
|
|
2985
4949
|
async function deleteWorkspaceSnapshotBuilders(names) {
|
|
2986
|
-
const credentials = getVercelSandboxCredentials();
|
|
2987
4950
|
let hasError = false;
|
|
2988
4951
|
let firstError;
|
|
2989
4952
|
for (const name of new Set(names)) try {
|
|
2990
4953
|
await (await Sandbox.get({
|
|
2991
4954
|
name,
|
|
2992
4955
|
resume: false,
|
|
2993
|
-
...
|
|
4956
|
+
...credentialOptions()
|
|
2994
4957
|
})).delete();
|
|
2995
4958
|
} catch (error) {
|
|
2996
|
-
if (!
|
|
4959
|
+
if (!vercelSandboxErrors.isMissing(error)) {
|
|
2997
4960
|
if (!hasError) firstError = error;
|
|
2998
4961
|
hasError = true;
|
|
2999
4962
|
}
|
|
3000
4963
|
}
|
|
3001
4964
|
if (hasError) throw firstError;
|
|
3002
4965
|
}
|
|
4966
|
+
/** Vercel Sandbox implementation of the sandbox provider contract. */
|
|
4967
|
+
const vercelSandboxProvider = {
|
|
4968
|
+
errors: vercelSandboxErrors,
|
|
4969
|
+
createSession: createSandboxSession,
|
|
4970
|
+
getSession: getSandboxSession,
|
|
4971
|
+
createWorkspaceSnapshotBuilder,
|
|
4972
|
+
getWorkspaceSnapshotBuilder,
|
|
4973
|
+
deleteWorkspaceSnapshotBuilders
|
|
4974
|
+
};
|
|
4975
|
+
// src/chat/sandbox/provider.ts
|
|
4976
|
+
/** Return the sandbox provider for this deployment. */
|
|
4977
|
+
function getSandboxProvider() {
|
|
4978
|
+
return vercelSandboxProvider;
|
|
4979
|
+
}
|
|
4980
|
+
// src/chat/sandbox/guest-docker.ts
|
|
4981
|
+
/** Docker daemon config for Vercel sandbox guests (nested containers). */
|
|
4982
|
+
const DOCKER_DAEMON_JSON = `{
|
|
4983
|
+
"storage-driver": "overlay2",
|
|
4984
|
+
"live-restore": false
|
|
4985
|
+
}
|
|
4986
|
+
`;
|
|
4987
|
+
/**
|
|
4988
|
+
* Start dockerd when the socket is missing and wait until `docker info` works.
|
|
4989
|
+
* Re-execs through sudo so the sandbox user can call it without root.
|
|
4990
|
+
*/
|
|
4991
|
+
const DOCKER_ENSURE_SCRIPT = `#!/bin/sh
|
|
4992
|
+
set -eu
|
|
4993
|
+
|
|
4994
|
+
if [ "$(id -u)" -ne 0 ]; then
|
|
4995
|
+
exec sudo -n "$0" "$@"
|
|
4996
|
+
fi
|
|
4997
|
+
|
|
4998
|
+
SOCK=/var/run/docker.sock
|
|
4999
|
+
PIDFILE=/var/run/docker.pid
|
|
5000
|
+
LOG=/var/log/junior-dockerd.log
|
|
5001
|
+
|
|
5002
|
+
is_ready() {
|
|
5003
|
+
[ -S "$SOCK" ] && /usr/bin/docker info >/dev/null 2>&1
|
|
5004
|
+
}
|
|
5005
|
+
|
|
5006
|
+
if is_ready; then
|
|
5007
|
+
chmod 666 "$SOCK" 2>/dev/null || true
|
|
5008
|
+
exit 0
|
|
5009
|
+
fi
|
|
5010
|
+
|
|
5011
|
+
mkdir -p /etc/docker /var/lib/docker /var/run /var/log
|
|
5012
|
+
if [ ! -f /etc/docker/daemon.json ]; then
|
|
5013
|
+
cat > /etc/docker/daemon.json <<'EOF'
|
|
5014
|
+
{
|
|
5015
|
+
"storage-driver": "overlay2",
|
|
5016
|
+
"live-restore": false
|
|
5017
|
+
}
|
|
5018
|
+
EOF
|
|
5019
|
+
fi
|
|
5020
|
+
|
|
5021
|
+
if [ -f "$PIDFILE" ]; then
|
|
5022
|
+
oldpid="$(cat "$PIDFILE" 2>/dev/null || true)"
|
|
5023
|
+
if [ -n "\${oldpid:-}" ] && ! kill -0 "$oldpid" 2>/dev/null; then
|
|
5024
|
+
rm -f "$PIDFILE"
|
|
5025
|
+
fi
|
|
5026
|
+
fi
|
|
5027
|
+
|
|
5028
|
+
if [ ! -f "$PIDFILE" ] || ! kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
|
|
5029
|
+
# dockerd must outlive this helper; nohup + background is intentional.
|
|
5030
|
+
nohup dockerd --host="unix://$SOCK" --pidfile="$PIDFILE" >"$LOG" 2>&1 &
|
|
5031
|
+
fi
|
|
5032
|
+
|
|
5033
|
+
i=0
|
|
5034
|
+
while [ "$i" -lt 50 ]; do
|
|
5035
|
+
if is_ready; then
|
|
5036
|
+
chmod 666 "$SOCK" 2>/dev/null || true
|
|
5037
|
+
exit 0
|
|
5038
|
+
fi
|
|
5039
|
+
i=$((i + 1))
|
|
5040
|
+
sleep 0.1
|
|
5041
|
+
done
|
|
5042
|
+
|
|
5043
|
+
echo "junior-ensure-docker: dockerd failed to become ready" >&2
|
|
5044
|
+
if [ -f "$LOG" ]; then
|
|
5045
|
+
tail -n 50 "$LOG" >&2 || true
|
|
5046
|
+
fi
|
|
5047
|
+
exit 1
|
|
5048
|
+
`;
|
|
5049
|
+
/** PATH wrapper so plain `docker` starts the daemon on first use. */
|
|
5050
|
+
const DOCKER_CLI_WRAPPER_SCRIPT = `#!/bin/sh
|
|
5051
|
+
set -eu
|
|
5052
|
+
if command -v junior-ensure-docker >/dev/null 2>&1; then
|
|
5053
|
+
junior-ensure-docker
|
|
5054
|
+
fi
|
|
5055
|
+
exec /usr/bin/docker "$@"
|
|
5056
|
+
`;
|
|
5057
|
+
/** PATH wrapper so plain `docker-compose` starts the daemon on first use. */
|
|
5058
|
+
const DOCKER_COMPOSE_CLI_WRAPPER_SCRIPT = `#!/bin/sh
|
|
5059
|
+
set -eu
|
|
5060
|
+
if command -v junior-ensure-docker >/dev/null 2>&1; then
|
|
5061
|
+
junior-ensure-docker
|
|
5062
|
+
fi
|
|
5063
|
+
exec /usr/local/libexec/junior-docker-compose "$@"
|
|
5064
|
+
`;
|
|
5065
|
+
/**
|
|
5066
|
+
* Best-effort dockerd start during sandbox prepare.
|
|
5067
|
+
* Missing helper is a no-op so tests and base sandboxes stay green.
|
|
5068
|
+
*/
|
|
5069
|
+
async function ensureDockerDaemon(sandbox, signal) {
|
|
5070
|
+
await runNonInteractiveCommand(sandbox, {
|
|
5071
|
+
cmd: "sh",
|
|
5072
|
+
args: ["-c", "command -v junior-ensure-docker >/dev/null 2>&1 && junior-ensure-docker || true"],
|
|
5073
|
+
signal
|
|
5074
|
+
});
|
|
5075
|
+
}
|
|
5076
|
+
// src/chat/sandbox/runtime-dependencies.ts
|
|
5077
|
+
const GLOBAL_RUNTIME_DEPENDENCIES = [
|
|
5078
|
+
{
|
|
5079
|
+
type: "system",
|
|
5080
|
+
package: "docker"
|
|
5081
|
+
},
|
|
5082
|
+
{
|
|
5083
|
+
type: "system",
|
|
5084
|
+
package: "spal-release"
|
|
5085
|
+
},
|
|
5086
|
+
{
|
|
5087
|
+
type: "system",
|
|
5088
|
+
package: "ripgrep"
|
|
5089
|
+
}
|
|
5090
|
+
];
|
|
5091
|
+
function writeFileCommand(path, contents, mode = "0644") {
|
|
5092
|
+
return [`printf '%s' '${Buffer.from(contents, "utf8").toString("base64")}' | base64 -d > ${path}`, `chmod ${mode} ${path}`].join("\n");
|
|
5093
|
+
}
|
|
5094
|
+
function writeExecutableCommand(path, contents) {
|
|
5095
|
+
return writeFileCommand(path, contents, "0755");
|
|
5096
|
+
}
|
|
5097
|
+
const GLOBAL_RUNTIME_POSTINSTALL = [{
|
|
5098
|
+
cmd: "sh",
|
|
5099
|
+
args: ["-c", [
|
|
5100
|
+
"set -eu",
|
|
5101
|
+
"case \"$(uname -m)\" in",
|
|
5102
|
+
" x86_64) asset=\"x86_64\"; sha256=\"7af95166a730b87e172d4fc9aefea8725d3c6c7327d59149267b452114ddb7d4\" ;;",
|
|
5103
|
+
" aarch64) asset=\"aarch64\"; sha256=\"49082844b87f03cdcd5f5bbef1ba8c9c897b7a2dfb80cea18d61ec8ca6117e0c\" ;;",
|
|
5104
|
+
" *) echo \"unsupported Docker Compose architecture: $(uname -m)\" >&2; exit 1 ;;",
|
|
5105
|
+
"esac",
|
|
5106
|
+
"tmp=\"$(mktemp)\"",
|
|
5107
|
+
"trap 'rm -f \"$tmp\"' EXIT",
|
|
5108
|
+
"curl --fail --location --silent --show-error \"https://github.com/docker/compose/releases/download/v2.39.4/docker-compose-linux-${asset}\" --output \"$tmp\"",
|
|
5109
|
+
"echo \"${sha256} ${tmp}\" | sha256sum --check --status",
|
|
5110
|
+
"install -D -m 0755 \"$tmp\" /usr/local/libexec/junior-docker-compose",
|
|
5111
|
+
"mkdir -p /usr/local/lib/docker/cli-plugins /usr/local/libexec /etc/docker /usr/local/bin",
|
|
5112
|
+
`mkdir -p ${SANDBOX_WORKSPACE_ROOT}/.junior/bin`,
|
|
5113
|
+
writeFileCommand("/etc/docker/daemon.json", DOCKER_DAEMON_JSON),
|
|
5114
|
+
writeExecutableCommand("/usr/local/bin/junior-ensure-docker", DOCKER_ENSURE_SCRIPT),
|
|
5115
|
+
"ln -sfn /usr/local/libexec/junior-docker-compose /usr/local/lib/docker/cli-plugins/docker-compose",
|
|
5116
|
+
writeExecutableCommand("/usr/local/bin/docker-compose", DOCKER_COMPOSE_CLI_WRAPPER_SCRIPT),
|
|
5117
|
+
writeExecutableCommand(`${SANDBOX_WORKSPACE_ROOT}/.junior/bin/docker`, DOCKER_CLI_WRAPPER_SCRIPT),
|
|
5118
|
+
writeExecutableCommand(`${SANDBOX_WORKSPACE_ROOT}/.junior/bin/docker-compose`, DOCKER_COMPOSE_CLI_WRAPPER_SCRIPT),
|
|
5119
|
+
writeExecutableCommand("/usr/local/bin/docker", DOCKER_CLI_WRAPPER_SCRIPT)
|
|
5120
|
+
].join("\n")],
|
|
5121
|
+
sudo: true
|
|
5122
|
+
}];
|
|
5123
|
+
// src/chat/sandbox/snapshot/profile.ts
|
|
5124
|
+
const VERSION = 1;
|
|
5125
|
+
const DEFAULT_FLOATING_MAX_AGE_MS = 6048e5;
|
|
5126
|
+
function isExactNpmVersion(version) {
|
|
5127
|
+
return /^\d+\.\d+\.\d+(?:[-+][a-z0-9.]+)?$/i.test(version.trim());
|
|
5128
|
+
}
|
|
5129
|
+
function isFloating(dep) {
|
|
5130
|
+
return dep.type === "npm" && !isExactNpmVersion(dep.version);
|
|
5131
|
+
}
|
|
5132
|
+
/**
|
|
5133
|
+
* Merge dependencies for one global install, rejecting competing npm versions.
|
|
5134
|
+
*/
|
|
5135
|
+
function mergeDependencies(dependencies) {
|
|
5136
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5137
|
+
const npmVersions = /* @__PURE__ */ new Map();
|
|
5138
|
+
const merged = [];
|
|
5139
|
+
for (const dependency of dependencies) {
|
|
5140
|
+
const key = dependency.type === "npm" ? `${dependency.type}:${dependency.package}:${dependency.version}` : "package" in dependency ? `${dependency.type}:package:${dependency.package}` : `${dependency.type}:url:${dependency.url}:${dependency.sha256}`;
|
|
5141
|
+
if (seen.has(key)) continue;
|
|
5142
|
+
seen.add(key);
|
|
5143
|
+
if (dependency.type === "npm") {
|
|
5144
|
+
const version = npmVersions.get(dependency.package);
|
|
5145
|
+
if (version !== void 0 && version !== dependency.version) throw new Error(`Conflicting runtime dependency versions for ${dependency.package}: ${version} and ${dependency.version}`);
|
|
5146
|
+
npmVersions.set(dependency.package, dependency.version);
|
|
5147
|
+
}
|
|
5148
|
+
merged.push(dependency);
|
|
5149
|
+
}
|
|
5150
|
+
return merged;
|
|
5151
|
+
}
|
|
5152
|
+
function floatingMaxAgeMs() {
|
|
5153
|
+
const raw = process.env.SANDBOX_SNAPSHOT_FLOATING_MAX_AGE_MS;
|
|
5154
|
+
if (!raw?.trim()) return DEFAULT_FLOATING_MAX_AGE_MS;
|
|
5155
|
+
const parsed = Number.parseInt(raw, 10);
|
|
5156
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_FLOATING_MAX_AGE_MS;
|
|
5157
|
+
}
|
|
5158
|
+
/** Locale-independent string order for stable profile hashes. */
|
|
5159
|
+
function compareCodePoints(left, right) {
|
|
5160
|
+
if (left < right) return -1;
|
|
5161
|
+
if (left > right) return 1;
|
|
5162
|
+
return 0;
|
|
5163
|
+
}
|
|
5164
|
+
function workspaceRecipe(workspace) {
|
|
5165
|
+
const repos = [...workspace.repos].map(({ provider, repo }) => ({
|
|
5166
|
+
provider,
|
|
5167
|
+
repo
|
|
5168
|
+
})).sort((left, right) => {
|
|
5169
|
+
const provider = compareCodePoints(left.provider, right.provider);
|
|
5170
|
+
if (provider !== 0) return provider;
|
|
5171
|
+
return compareCodePoints(left.repo, right.repo);
|
|
5172
|
+
});
|
|
5173
|
+
return {
|
|
5174
|
+
id: workspace.id,
|
|
5175
|
+
repos,
|
|
5176
|
+
setupScript: workspace.setupScript
|
|
5177
|
+
};
|
|
5178
|
+
}
|
|
5179
|
+
/** Build the complete profile that selects one reusable sandbox snapshot. */
|
|
5180
|
+
function create(runtime, workspace) {
|
|
5181
|
+
const dependencies = mergeDependencies([...GLOBAL_RUNTIME_DEPENDENCIES, ...pluginCatalogRuntime.getRuntimeDependencies()]);
|
|
5182
|
+
const pluginPostinstall = pluginCatalogRuntime.getRuntimePostinstall();
|
|
5183
|
+
const postinstall = [...GLOBAL_RUNTIME_POSTINSTALL, ...pluginPostinstall];
|
|
5184
|
+
if (dependencies.length === 0 && postinstall.length === 0 && !workspace) return null;
|
|
5185
|
+
const rebuildEpoch = process.env.SANDBOX_SNAPSHOT_REBUILD_EPOCH?.trim() ?? "";
|
|
5186
|
+
const floating = dependencies.some((dependency) => isFloating(dependency)) || pluginPostinstall.length > 0 || Boolean(workspace);
|
|
5187
|
+
return {
|
|
5188
|
+
hash: createHash("sha256").update(JSON.stringify({
|
|
5189
|
+
version: VERSION,
|
|
5190
|
+
runtime,
|
|
5191
|
+
rebuildEpoch,
|
|
5192
|
+
dependencies,
|
|
5193
|
+
postinstall,
|
|
5194
|
+
...workspace ? { workspace: workspaceRecipe(workspace) } : void 0
|
|
5195
|
+
})).digest("hex"),
|
|
5196
|
+
dependencyCount: dependencies.length,
|
|
5197
|
+
floating,
|
|
5198
|
+
dependencies,
|
|
5199
|
+
postinstall
|
|
5200
|
+
};
|
|
5201
|
+
}
|
|
5202
|
+
/** Return the current dependency profile hash without building its snapshot. */
|
|
5203
|
+
function hash(runtime, workspace) {
|
|
5204
|
+
return create(runtime, workspace)?.hash;
|
|
5205
|
+
}
|
|
5206
|
+
/** Decide whether a cached snapshot has outlived a floating profile. */
|
|
5207
|
+
function isStale(profile, createdAtMs) {
|
|
5208
|
+
if (!profile.floating) return false;
|
|
5209
|
+
const maxAgeMs = floatingMaxAgeMs();
|
|
5210
|
+
return maxAgeMs === 0 || Date.now() - createdAtMs > maxAgeMs;
|
|
5211
|
+
}
|
|
3003
5212
|
// src/chat/sandbox/snapshot/runtime.ts
|
|
3004
5213
|
/** Runtime used to build and start reusable Sandbox snapshots. */
|
|
3005
5214
|
const SANDBOX_RUNTIME = "node22";
|
|
@@ -3244,7 +5453,7 @@ function isUniqueViolation(error) {
|
|
|
3244
5453
|
async function cleanupSnapshotBuilders(workspaceId, names) {
|
|
3245
5454
|
if (names.length === 0) return;
|
|
3246
5455
|
try {
|
|
3247
|
-
await deleteWorkspaceSnapshotBuilders(names);
|
|
5456
|
+
await getSandboxProvider().deleteWorkspaceSnapshotBuilders(names);
|
|
3248
5457
|
} catch (error) {
|
|
3249
5458
|
logException(error, "sandbox.workspace_snapshot.builder.delete_failed", {
|
|
3250
5459
|
"app.workspace.id": workspaceId,
|
|
@@ -3497,7 +5706,7 @@ const systemPromptMessageArraySchema = z.array(promptMessageSchema);
|
|
|
3497
5706
|
function isRecord(value) {
|
|
3498
5707
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
3499
5708
|
}
|
|
3500
|
-
function basePluginContext(plugin) {
|
|
5709
|
+
function basePluginContext$1(plugin) {
|
|
3501
5710
|
const name = plugin.manifest.name;
|
|
3502
5711
|
return {
|
|
3503
5712
|
plugin: { name },
|
|
@@ -3512,7 +5721,7 @@ function pluginToolNamespace(pluginName) {
|
|
|
3512
5721
|
return `${first}${rest.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join("")}`;
|
|
3513
5722
|
}
|
|
3514
5723
|
function systemPromptPluginContext(plugin) {
|
|
3515
|
-
return { ...basePluginContext(plugin) };
|
|
5724
|
+
return { ...basePluginContext$1(plugin) };
|
|
3516
5725
|
}
|
|
3517
5726
|
/** Bind Source, Destination, and Actor for one plugin invocation. */
|
|
3518
5727
|
function pluginInvocationContext(context) {
|
|
@@ -3557,7 +5766,7 @@ function pluginInvocationContext(context) {
|
|
|
3557
5766
|
}
|
|
3558
5767
|
function invocationPluginContext(plugin, context, turnId) {
|
|
3559
5768
|
return {
|
|
3560
|
-
...basePluginContext(plugin),
|
|
5769
|
+
...basePluginContext$1(plugin),
|
|
3561
5770
|
conversationId: context.conversationId,
|
|
3562
5771
|
locationId: context.locationId,
|
|
3563
5772
|
embedder: createPluginEmbedder(plugin.manifest.name),
|
|
@@ -3832,7 +6041,7 @@ function getPluginTools(context, sandbox = createSandboxCapability(context.works
|
|
|
3832
6041
|
const resolveActor = context.resolveActorIdentity ?? (async () => void 0);
|
|
3833
6042
|
const automationUrl = (context.source.kind === "event_automation" || context.source.kind === "scheduled_automation") && context.source.automationId ? getDashboardTaskLink(context.source.automationId) : void 0;
|
|
3834
6043
|
const common = {
|
|
3835
|
-
...basePluginContext(plugin),
|
|
6044
|
+
...basePluginContext$1(plugin),
|
|
3836
6045
|
annotations,
|
|
3837
6046
|
...automationUrl ? { automationLink: { url: automationUrl } } : void 0,
|
|
3838
6047
|
conversationId: context.conversationId,
|
|
@@ -3969,13 +6178,14 @@ function getPluginRoutes(options) {
|
|
|
3969
6178
|
const hook = plugin.hooks?.routes;
|
|
3970
6179
|
if (!hook) continue;
|
|
3971
6180
|
const pluginRoutes = hook({
|
|
3972
|
-
...basePluginContext(plugin),
|
|
6181
|
+
...basePluginContext$1(plugin),
|
|
3973
6182
|
annotations: { forConversation: (conversationId) => createPluginAnnotations({
|
|
3974
6183
|
conversationId,
|
|
3975
6184
|
db: getDb(),
|
|
3976
6185
|
plugin: pluginName
|
|
3977
6186
|
}) },
|
|
3978
6187
|
codeChanges: createCodeChangePublisher(pluginName),
|
|
6188
|
+
users: { readIdentity: readIdentityById },
|
|
3979
6189
|
events: {
|
|
3980
6190
|
async hasMatch(event) {
|
|
3981
6191
|
if (!options.events.hasMatch) return false;
|
|
@@ -4039,7 +6249,7 @@ function getPluginApiRoutes() {
|
|
|
4039
6249
|
const hook = plugin.hooks?.apiRoutes;
|
|
4040
6250
|
if (!hook) continue;
|
|
4041
6251
|
const app = hook({
|
|
4042
|
-
...basePluginContext(plugin),
|
|
6252
|
+
...basePluginContext$1(plugin),
|
|
4043
6253
|
conversationEvents: createPluginConversationEventReader(plugin),
|
|
4044
6254
|
eventStats: createPluginConversationEventStats(plugin),
|
|
4045
6255
|
users: { resolve: resolveViewerUser }
|
|
@@ -4073,7 +6283,7 @@ function getPluginSlackConversationLink(conversationId) {
|
|
|
4073
6283
|
const hook = plugin.hooks?.slackConversationLink;
|
|
4074
6284
|
if (!hook) continue;
|
|
4075
6285
|
const url = trustedSlackConversationUrl(pluginName, hook({
|
|
4076
|
-
...basePluginContext(plugin),
|
|
6286
|
+
...basePluginContext$1(plugin),
|
|
4077
6287
|
conversationId
|
|
4078
6288
|
}));
|
|
4079
6289
|
if (url) return { url };
|
|
@@ -4211,7 +6421,7 @@ async function getPluginOperationalReports(nowMs) {
|
|
|
4211
6421
|
try {
|
|
4212
6422
|
const state = createPluginState(pluginName);
|
|
4213
6423
|
const report = await hook({
|
|
4214
|
-
...basePluginContext(plugin),
|
|
6424
|
+
...basePluginContext$1(plugin),
|
|
4215
6425
|
eventStats: createPluginConversationEventStats(plugin, () => nowMs),
|
|
4216
6426
|
nowMs,
|
|
4217
6427
|
state: pluginReadState(state)
|
|
@@ -4241,7 +6451,7 @@ async function getPluginProfileReports(args) {
|
|
|
4241
6451
|
try {
|
|
4242
6452
|
const state = createPluginState(pluginName);
|
|
4243
6453
|
const report = await hook({
|
|
4244
|
-
...basePluginContext(plugin),
|
|
6454
|
+
...basePluginContext$1(plugin),
|
|
4245
6455
|
nowMs: args.nowMs,
|
|
4246
6456
|
state: pluginReadState(state),
|
|
4247
6457
|
subject: args.subject,
|
|
@@ -4314,7 +6524,7 @@ function createPluginHookRunner(input = {}) {
|
|
|
4314
6524
|
}) : void 0;
|
|
4315
6525
|
try {
|
|
4316
6526
|
const result = await hook({
|
|
4317
|
-
...basePluginContext(plugin),
|
|
6527
|
+
...basePluginContext$1(plugin),
|
|
4318
6528
|
...tool.conversationId ? { conversationId: tool.conversationId } : void 0,
|
|
4319
6529
|
...annotations ? { annotations } : void 0,
|
|
4320
6530
|
result: {
|
|
@@ -4363,7 +6573,7 @@ function createPluginHookRunner(input = {}) {
|
|
|
4363
6573
|
}));
|
|
4364
6574
|
if (selected.length === 0) continue;
|
|
4365
6575
|
const finalize = await hook({
|
|
4366
|
-
...basePluginContext(plugin),
|
|
6576
|
+
...basePluginContext$1(plugin),
|
|
4367
6577
|
repos: selected,
|
|
4368
6578
|
sandbox: sandboxCapability
|
|
4369
6579
|
});
|
|
@@ -4381,7 +6591,7 @@ function createPluginHookRunner(input = {}) {
|
|
|
4381
6591
|
if (!hook) continue;
|
|
4382
6592
|
logInfo("agent.plugin.sandbox_preparation.started", { "app.plugin.name": pluginName });
|
|
4383
6593
|
await hook({
|
|
4384
|
-
...basePluginContext(plugin),
|
|
6594
|
+
...basePluginContext$1(plugin),
|
|
4385
6595
|
actor: input.actor,
|
|
4386
6596
|
sandbox: sandboxCapability
|
|
4387
6597
|
});
|
|
@@ -4398,7 +6608,7 @@ function createPluginHookRunner(input = {}) {
|
|
|
4398
6608
|
let replacement;
|
|
4399
6609
|
let blocked;
|
|
4400
6610
|
await hook({
|
|
4401
|
-
...basePluginContext(plugin),
|
|
6611
|
+
...basePluginContext$1(plugin),
|
|
4402
6612
|
actor: input.actor,
|
|
4403
6613
|
actors,
|
|
4404
6614
|
tool: {
|
|
@@ -4439,4 +6649,925 @@ function createPluginHookRunner(input = {}) {
|
|
|
4439
6649
|
}
|
|
4440
6650
|
};
|
|
4441
6651
|
}
|
|
4442
|
-
|
|
6652
|
+
// src/chat/plugins/credential-hooks.ts
|
|
6653
|
+
function parseSchema(schema, value, message) {
|
|
6654
|
+
const result = schema.safeParse(value);
|
|
6655
|
+
if (!result.success) throw new Error(message);
|
|
6656
|
+
return result.data;
|
|
6657
|
+
}
|
|
6658
|
+
function parseAuthorization(value, pluginName) {
|
|
6659
|
+
if (value === void 0) return;
|
|
6660
|
+
const authorization = parseSchema(pluginAuthorizationSchema, value, `Plugin "${pluginName}" grant authorization is invalid`);
|
|
6661
|
+
if (authorization.provider !== pluginName) throw new Error(`Plugin "${pluginName}" grant authorization provider must match the issuing plugin`);
|
|
6662
|
+
return authorization;
|
|
6663
|
+
}
|
|
6664
|
+
function parseGrant(value, pluginName) {
|
|
6665
|
+
return parseSchema(pluginGrantSchema, value, `Plugin "${pluginName}" grantForEgress returned an invalid grant`);
|
|
6666
|
+
}
|
|
6667
|
+
function pluginFor(provider) {
|
|
6668
|
+
return getPlugins().find((candidate) => candidate.manifest.name === provider);
|
|
6669
|
+
}
|
|
6670
|
+
function basePluginContext(plugin) {
|
|
6671
|
+
const pluginName = plugin.manifest.name;
|
|
6672
|
+
return {
|
|
6673
|
+
plugin: { name: pluginName },
|
|
6674
|
+
cache: createPluginCache(pluginName),
|
|
6675
|
+
log: createPluginLogger(pluginName),
|
|
6676
|
+
db: getDb()
|
|
6677
|
+
};
|
|
6678
|
+
}
|
|
6679
|
+
function parseCredentialResult(value, pluginName) {
|
|
6680
|
+
const result = parseSchema(pluginCredentialResultSchema, value, `Plugin "${pluginName}" issueCredential result is invalid`);
|
|
6681
|
+
if (result.type === "lease") {
|
|
6682
|
+
parseAuthorization(result.lease.authorization, pluginName);
|
|
6683
|
+
return result;
|
|
6684
|
+
}
|
|
6685
|
+
if (result.type === "unavailable") return result;
|
|
6686
|
+
parseAuthorization(result.authorization, pluginName);
|
|
6687
|
+
return result;
|
|
6688
|
+
}
|
|
6689
|
+
/** Ask a plugin which grant an outbound request needs. */
|
|
6690
|
+
async function selectPluginGrant(input) {
|
|
6691
|
+
const plugin = pluginFor(input.provider);
|
|
6692
|
+
const hook = plugin?.hooks?.grantForEgress;
|
|
6693
|
+
if (!plugin || !hook) return;
|
|
6694
|
+
const result = await hook({
|
|
6695
|
+
...basePluginContext(plugin),
|
|
6696
|
+
request: {
|
|
6697
|
+
...input.bodyText !== void 0 ? { bodyText: input.bodyText } : void 0,
|
|
6698
|
+
method: input.method,
|
|
6699
|
+
...input.operation ? { operation: input.operation } : void 0,
|
|
6700
|
+
url: input.upstreamUrl.toString()
|
|
6701
|
+
}
|
|
6702
|
+
});
|
|
6703
|
+
return result === void 0 ? void 0 : parseGrant(result, plugin.manifest.name);
|
|
6704
|
+
}
|
|
6705
|
+
/** Let the owning plugin inspect an upstream response without changing pass-through behavior. */
|
|
6706
|
+
async function onPluginEgressResponse(input) {
|
|
6707
|
+
const plugin = pluginFor(input.provider);
|
|
6708
|
+
const hook = plugin?.hooks?.onEgressResponse;
|
|
6709
|
+
if (!plugin || !hook) return {};
|
|
6710
|
+
let permissionDenied;
|
|
6711
|
+
await hook({
|
|
6712
|
+
...basePluginContext(plugin),
|
|
6713
|
+
grant: input.grant,
|
|
6714
|
+
permissionDenied(message) {
|
|
6715
|
+
const trimmed = message.trim();
|
|
6716
|
+
if (!trimmed) throw new Error(`Plugin "${plugin.manifest.name}" onEgressResponse permissionDenied message is empty`);
|
|
6717
|
+
permissionDenied = { message: trimmed };
|
|
6718
|
+
},
|
|
6719
|
+
request: {
|
|
6720
|
+
method: input.method,
|
|
6721
|
+
...input.operation ? { operation: input.operation } : void 0,
|
|
6722
|
+
url: input.upstreamUrl.toString()
|
|
6723
|
+
},
|
|
6724
|
+
response: input.response
|
|
6725
|
+
});
|
|
6726
|
+
return permissionDenied ? { permissionDenied } : {};
|
|
6727
|
+
}
|
|
6728
|
+
/** Return whether a plugin owns credential issuance for egress. */
|
|
6729
|
+
function hasEgressCredentialHooks(provider) {
|
|
6730
|
+
const hooks = pluginFor(provider)?.hooks;
|
|
6731
|
+
return Boolean(hooks?.grantForEgress || hooks?.issueCredential);
|
|
6732
|
+
}
|
|
6733
|
+
/** Return providers that can verify an OAuth account for identity storage. */
|
|
6734
|
+
function getOAuthAccountProviders() {
|
|
6735
|
+
return getPlugins().filter((plugin) => Boolean(plugin.hooks?.resolveOAuthAccount)).map((plugin) => plugin.manifest.name);
|
|
6736
|
+
}
|
|
6737
|
+
/** Ask a plugin which provider account belongs to an OAuth token. */
|
|
6738
|
+
async function resolvePluginOAuthAccount(input) {
|
|
6739
|
+
const plugin = pluginFor(input.provider);
|
|
6740
|
+
const hook = plugin?.hooks?.resolveOAuthAccount;
|
|
6741
|
+
if (!plugin || !hook) return;
|
|
6742
|
+
const account = await hook({
|
|
6743
|
+
...basePluginContext(plugin),
|
|
6744
|
+
tokenResponse: input.tokenResponse,
|
|
6745
|
+
tokens: input.tokens
|
|
6746
|
+
});
|
|
6747
|
+
return account === void 0 ? void 0 : parseSchema(pluginProviderAccountSchema, account, `Plugin "${plugin.manifest.name}" resolveOAuthAccount returned an invalid account`);
|
|
6748
|
+
}
|
|
6749
|
+
/** Ask a plugin to issue headers or describe why the selected grant is unavailable. */
|
|
6750
|
+
async function issuePluginCredential(input) {
|
|
6751
|
+
const plugin = pluginFor(input.provider);
|
|
6752
|
+
const hook = plugin?.hooks?.issueCredential;
|
|
6753
|
+
if (!plugin || !hook) throw new Error(`Plugin "${input.provider}" has no issueCredential hook`);
|
|
6754
|
+
const currentUserId = "type" in input.actor ? input.actor.userId : void 0;
|
|
6755
|
+
const credentialSubjectUserId = input.credentialSubject?.userId;
|
|
6756
|
+
return parseCredentialResult(await hook({
|
|
6757
|
+
...basePluginContext(plugin),
|
|
6758
|
+
actor: input.actor,
|
|
6759
|
+
grant: input.grant,
|
|
6760
|
+
...input.credentialSubject ? { credentialSubject: input.credentialSubject } : void 0,
|
|
6761
|
+
tokens: {
|
|
6762
|
+
...currentUserId ? { currentUser: {
|
|
6763
|
+
userId: currentUserId,
|
|
6764
|
+
get: async () => await input.userTokenStore.get(currentUserId, plugin.manifest.name),
|
|
6765
|
+
set: async (tokens) => {
|
|
6766
|
+
await input.userTokenStore.set(currentUserId, plugin.manifest.name, tokens);
|
|
6767
|
+
},
|
|
6768
|
+
withRefresh: async (callback) => await input.userTokenStore.withRefresh(currentUserId, plugin.manifest.name, callback)
|
|
6769
|
+
} } : void 0,
|
|
6770
|
+
...credentialSubjectUserId ? { credentialSubject: {
|
|
6771
|
+
userId: credentialSubjectUserId,
|
|
6772
|
+
get: async () => await input.userTokenStore.get(credentialSubjectUserId, plugin.manifest.name),
|
|
6773
|
+
set: async (tokens) => {
|
|
6774
|
+
await input.userTokenStore.set(credentialSubjectUserId, plugin.manifest.name, tokens);
|
|
6775
|
+
},
|
|
6776
|
+
withRefresh: async (callback) => await input.userTokenStore.withRefresh(credentialSubjectUserId, plugin.manifest.name, callback)
|
|
6777
|
+
} } : void 0
|
|
6778
|
+
}
|
|
6779
|
+
}), plugin.manifest.name);
|
|
6780
|
+
}
|
|
6781
|
+
// src/chat/capabilities/router.ts
|
|
6782
|
+
var ProviderCredentialRouter = class {
|
|
6783
|
+
brokersByProvider;
|
|
6784
|
+
constructor(input) {
|
|
6785
|
+
this.brokersByProvider = input.brokersByProvider;
|
|
6786
|
+
}
|
|
6787
|
+
async issue(input) {
|
|
6788
|
+
const broker = this.brokersByProvider[input.provider];
|
|
6789
|
+
if (!broker) throw new Error(`No credential broker registered for provider: ${input.provider}`);
|
|
6790
|
+
return await broker.issue({
|
|
6791
|
+
context: input.context,
|
|
6792
|
+
reason: input.reason
|
|
6793
|
+
});
|
|
6794
|
+
}
|
|
6795
|
+
};
|
|
6796
|
+
// src/chat/credentials/user-token-store.ts
|
|
6797
|
+
const storedTokensSchema = pluginStoredTokensSchema;
|
|
6798
|
+
// src/chat/credentials/state-adapter-token-store.ts
|
|
6799
|
+
const KEY_PREFIX = "oauth-token";
|
|
6800
|
+
const BUFFER_MS = 864e5;
|
|
6801
|
+
const LONG_LIVED_TTL_MS = 31536e6;
|
|
6802
|
+
const REFRESH_LOCK_WAIT_MS = 3e4;
|
|
6803
|
+
const REFRESH_LOCK_RETRY_MS = 100;
|
|
6804
|
+
function tokenKey(userId, provider) {
|
|
6805
|
+
return `${KEY_PREFIX}:${userId}:${provider}`;
|
|
6806
|
+
}
|
|
6807
|
+
function refreshLockKey(userId, provider) {
|
|
6808
|
+
return `${tokenKey(userId, provider)}:refresh`;
|
|
6809
|
+
}
|
|
6810
|
+
var StateAdapterTokenStore = class {
|
|
6811
|
+
state;
|
|
6812
|
+
constructor(stateAdapter) {
|
|
6813
|
+
this.state = stateAdapter;
|
|
6814
|
+
}
|
|
6815
|
+
async get(userId, provider) {
|
|
6816
|
+
const stored = await this.state.get(tokenKey(userId, provider));
|
|
6817
|
+
return stored === null || stored === void 0 ? void 0 : storedTokensSchema.parse(stored);
|
|
6818
|
+
}
|
|
6819
|
+
async set(userId, provider, tokens) {
|
|
6820
|
+
const parsed = storedTokensSchema.parse(tokens);
|
|
6821
|
+
const expiresAt = parsed.refreshTokenExpiresAt ?? parsed.expiresAt;
|
|
6822
|
+
const ttlMs = expiresAt ? Math.max(expiresAt - Date.now() + BUFFER_MS, BUFFER_MS) : LONG_LIVED_TTL_MS;
|
|
6823
|
+
await this.state.set(tokenKey(userId, provider), parsed, ttlMs);
|
|
6824
|
+
}
|
|
6825
|
+
async delete(userId, provider) {
|
|
6826
|
+
await this.state.delete(tokenKey(userId, provider));
|
|
6827
|
+
}
|
|
6828
|
+
/** Wait for the per-slot refresh gate so rotated refresh tokens are used once. */
|
|
6829
|
+
async withRefresh(userId, provider, callback) {
|
|
6830
|
+
const lockKey = refreshLockKey(userId, provider);
|
|
6831
|
+
const deadline = Date.now() + REFRESH_LOCK_WAIT_MS;
|
|
6832
|
+
while (true) {
|
|
6833
|
+
const lock = await acquireActiveLock(this.state, lockKey);
|
|
6834
|
+
if (lock) try {
|
|
6835
|
+
return await callback();
|
|
6836
|
+
} finally {
|
|
6837
|
+
await this.state.releaseLock(lock);
|
|
6838
|
+
}
|
|
6839
|
+
if (Date.now() >= deadline) throw new Error(`Could not acquire OAuth token refresh lock`);
|
|
6840
|
+
await sleep$1(REFRESH_LOCK_RETRY_MS);
|
|
6841
|
+
}
|
|
6842
|
+
}
|
|
6843
|
+
};
|
|
6844
|
+
// src/chat/capabilities/factory.ts
|
|
6845
|
+
const sandboxEgressRouters = /* @__PURE__ */ new WeakMap();
|
|
6846
|
+
/** Create the user token store used by OAuth-backed credential brokers. */
|
|
6847
|
+
function createUserTokenStore() {
|
|
6848
|
+
return new StateAdapterTokenStore(getStateAdapter());
|
|
6849
|
+
}
|
|
6850
|
+
function createProviderCredentialRouter(userTokenStore) {
|
|
6851
|
+
const brokersByProvider = {};
|
|
6852
|
+
for (const plugin of pluginCatalogRuntime.getProviders()) {
|
|
6853
|
+
const { name } = plugin.manifest;
|
|
6854
|
+
if (!plugin.manifest.credentials && !plugin.manifest.apiHeaders) continue;
|
|
6855
|
+
brokersByProvider[name] = pluginCatalogRuntime.createBroker(name, { userTokenStore });
|
|
6856
|
+
}
|
|
6857
|
+
return new ProviderCredentialRouter({ brokersByProvider });
|
|
6858
|
+
}
|
|
6859
|
+
function getSandboxEgressRouter() {
|
|
6860
|
+
const stateAdapter = getStateAdapter();
|
|
6861
|
+
let router = sandboxEgressRouters.get(stateAdapter);
|
|
6862
|
+
if (!router) {
|
|
6863
|
+
router = createProviderCredentialRouter(new StateAdapterTokenStore(stateAdapter));
|
|
6864
|
+
sandboxEgressRouters.set(stateAdapter, router);
|
|
6865
|
+
}
|
|
6866
|
+
return router;
|
|
6867
|
+
}
|
|
6868
|
+
/** Issue one provider credential lease for host-side sandbox egress proxying. */
|
|
6869
|
+
async function issueProviderCredentialLease(input) {
|
|
6870
|
+
return await getSandboxEgressRouter().issue(input);
|
|
6871
|
+
}
|
|
6872
|
+
// src/chat/sandbox/egress/credentials.ts
|
|
6873
|
+
const HTTP_READ_METHODS = /* @__PURE__ */ new Set([
|
|
6874
|
+
"GET",
|
|
6875
|
+
"HEAD",
|
|
6876
|
+
"OPTIONS"
|
|
6877
|
+
]);
|
|
6878
|
+
/**
|
|
6879
|
+
* Signals that egress selected a grant but could not issue credential headers.
|
|
6880
|
+
*
|
|
6881
|
+
* Callers convert this into command-level auth-required state so the agent can
|
|
6882
|
+
* request authorization without exposing provider-specific lease internals.
|
|
6883
|
+
*/
|
|
6884
|
+
var SandboxEgressCredentialError = class extends Error {
|
|
6885
|
+
authorization;
|
|
6886
|
+
grant;
|
|
6887
|
+
kind;
|
|
6888
|
+
provider;
|
|
6889
|
+
constructor(input) {
|
|
6890
|
+
super(input.message);
|
|
6891
|
+
this.name = "SandboxEgressCredentialError";
|
|
6892
|
+
this.authorization = input.authorization;
|
|
6893
|
+
this.grant = input.grant;
|
|
6894
|
+
this.kind = input.kind;
|
|
6895
|
+
this.provider = input.provider;
|
|
6896
|
+
}
|
|
6897
|
+
};
|
|
6898
|
+
function defaultGrantForProvider(input) {
|
|
6899
|
+
const access = HTTP_READ_METHODS.has(input.method.toUpperCase()) ? "read" : "write";
|
|
6900
|
+
return {
|
|
6901
|
+
source: "broker",
|
|
6902
|
+
grant: {
|
|
6903
|
+
name: "default",
|
|
6904
|
+
access,
|
|
6905
|
+
reason: `sandbox-egress:${input.provider}:${access}`
|
|
6906
|
+
}
|
|
6907
|
+
};
|
|
6908
|
+
}
|
|
6909
|
+
function hasGenericCredentials(provider) {
|
|
6910
|
+
const manifest = pluginCatalogRuntime.getDefinition(provider)?.manifest;
|
|
6911
|
+
return Boolean(manifest?.credentials || manifest?.apiHeaders);
|
|
6912
|
+
}
|
|
6913
|
+
function oauthAuthorizationForProvider(provider) {
|
|
6914
|
+
const oauth = pluginCatalogRuntime.getOAuthConfig(provider);
|
|
6915
|
+
return oauth ? {
|
|
6916
|
+
type: "oauth",
|
|
6917
|
+
provider,
|
|
6918
|
+
...oauth.scope ? { scope: oauth.scope } : void 0
|
|
6919
|
+
} : void 0;
|
|
6920
|
+
}
|
|
6921
|
+
function credentialSubjectFromContext(context) {
|
|
6922
|
+
return "subject" in context.credentials && context.credentials.subject ? {
|
|
6923
|
+
type: "user",
|
|
6924
|
+
userId: context.credentials.subject.userId
|
|
6925
|
+
} : void 0;
|
|
6926
|
+
}
|
|
6927
|
+
function assertLeaseTransformsOwnedByProvider(provider, lease) {
|
|
6928
|
+
for (const transform of lease.headerTransforms) if (resolveSandboxEgressProviderForHost(transform.domain) !== provider) throw new Error(`Credential lease for ${provider} included header transform for unowned domain ${transform.domain}`);
|
|
6929
|
+
}
|
|
6930
|
+
/**
|
|
6931
|
+
* Select the grant needed for one outbound request.
|
|
6932
|
+
*
|
|
6933
|
+
* GitHub GraphQL and other plugin-owned APIs may need body-aware grant choices;
|
|
6934
|
+
* providers without hooks use a simple read/write default based on HTTP method.
|
|
6935
|
+
* A hook plugin that also declares generic credentials can return no grant to
|
|
6936
|
+
* use that default for one request.
|
|
6937
|
+
*/
|
|
6938
|
+
async function selectSandboxEgressGrant(input) {
|
|
6939
|
+
if (!hasEgressCredentialHooks(input.provider)) return defaultGrantForProvider(input);
|
|
6940
|
+
const pluginGrant = await selectPluginGrant({
|
|
6941
|
+
...input.bodyText !== void 0 ? { bodyText: input.bodyText } : void 0,
|
|
6942
|
+
...input.operation ? { operation: input.operation } : void 0,
|
|
6943
|
+
provider: input.provider,
|
|
6944
|
+
method: input.method,
|
|
6945
|
+
upstreamUrl: input.upstreamUrl
|
|
6946
|
+
});
|
|
6947
|
+
if (!pluginGrant) {
|
|
6948
|
+
if (hasGenericCredentials(input.provider)) return defaultGrantForProvider(input);
|
|
6949
|
+
throw new Error(`Plugin "${input.provider}" grantForEgress must return a grant for sandbox egress`);
|
|
6950
|
+
}
|
|
6951
|
+
return {
|
|
6952
|
+
source: "plugin",
|
|
6953
|
+
grant: pluginGrant
|
|
6954
|
+
};
|
|
6955
|
+
}
|
|
6956
|
+
/**
|
|
6957
|
+
* Resolve the authorization flow attached to a broker-selected egress grant.
|
|
6958
|
+
*
|
|
6959
|
+
* Plugin-selected grants can return their own authorization metadata when
|
|
6960
|
+
* issuing credentials; broker defaults use the provider OAuth config.
|
|
6961
|
+
*/
|
|
6962
|
+
function authorizationForSandboxEgressGrant(provider, selection) {
|
|
6963
|
+
return selection.source === "broker" ? oauthAuthorizationForProvider(provider) : void 0;
|
|
6964
|
+
}
|
|
6965
|
+
/**
|
|
6966
|
+
* Return credential header transforms for a selected grant.
|
|
6967
|
+
*
|
|
6968
|
+
* Only shared installation grants are remembered on the host. User and other
|
|
6969
|
+
* grants always issue from the live credential source so a saved login change
|
|
6970
|
+
* cannot leave stale auth headers in place. The sandbox context only authorizes
|
|
6971
|
+
* the request.
|
|
6972
|
+
*/
|
|
6973
|
+
async function sandboxEgressCredentialLease(provider, selection, context) {
|
|
6974
|
+
const { grant } = selection;
|
|
6975
|
+
const cached = await getSandboxEgressCredentialLease(provider, grant, context);
|
|
6976
|
+
if (cached) {
|
|
6977
|
+
if (selection.source === "plugin" && cached.grant.access !== grant.access) throw new Error(`Cached credential lease for ${provider}/${grant.name} has ${cached.grant.access} access, but ${grant.access} was selected`);
|
|
6978
|
+
return {
|
|
6979
|
+
...cached,
|
|
6980
|
+
grant
|
|
6981
|
+
};
|
|
6982
|
+
}
|
|
6983
|
+
let lease;
|
|
6984
|
+
if (selection.source === "plugin") {
|
|
6985
|
+
const credentialSubject = credentialSubjectFromContext(context);
|
|
6986
|
+
const pluginResult = await issuePluginCredential({
|
|
6987
|
+
provider,
|
|
6988
|
+
grant,
|
|
6989
|
+
actor: context.credentials.actor,
|
|
6990
|
+
...credentialSubject ? { credentialSubject } : void 0,
|
|
6991
|
+
userTokenStore: createUserTokenStore()
|
|
6992
|
+
});
|
|
6993
|
+
if (pluginResult.type === "needed") throw new SandboxEgressCredentialError({
|
|
6994
|
+
provider,
|
|
6995
|
+
grant,
|
|
6996
|
+
kind: "auth_required",
|
|
6997
|
+
authorization: pluginResult.authorization,
|
|
6998
|
+
message: pluginResult.message
|
|
6999
|
+
});
|
|
7000
|
+
if (pluginResult.type === "unavailable") throw new SandboxEgressCredentialError({
|
|
7001
|
+
provider,
|
|
7002
|
+
grant,
|
|
7003
|
+
kind: "unavailable",
|
|
7004
|
+
message: pluginResult.message
|
|
7005
|
+
});
|
|
7006
|
+
lease = pluginResult.lease;
|
|
7007
|
+
} else try {
|
|
7008
|
+
lease = await issueProviderCredentialLease({
|
|
7009
|
+
context: context.credentials,
|
|
7010
|
+
provider,
|
|
7011
|
+
reason: grant.reason ?? `sandbox-egress:${provider}:default`
|
|
7012
|
+
});
|
|
7013
|
+
} catch (error) {
|
|
7014
|
+
if (error instanceof CredentialUnavailableError) throw new SandboxEgressCredentialError({
|
|
7015
|
+
provider,
|
|
7016
|
+
grant,
|
|
7017
|
+
kind: "auth_required",
|
|
7018
|
+
authorization: authorizationForSandboxEgressGrant(provider, selection),
|
|
7019
|
+
message: error.message
|
|
7020
|
+
});
|
|
7021
|
+
throw error;
|
|
7022
|
+
}
|
|
7023
|
+
const headerTransforms = lease.headerTransforms ?? [];
|
|
7024
|
+
if (headerTransforms.length === 0) throw new Error(`Credential lease for ${provider} did not include header transforms`);
|
|
7025
|
+
const leaseExpiresAtMs = Date.parse(lease.expiresAt);
|
|
7026
|
+
if (!Number.isFinite(leaseExpiresAtMs) || leaseExpiresAtMs <= Date.now()) throw new Error(`Credential lease for ${provider} is expired`);
|
|
7027
|
+
const authorization = selection.source === "broker" ? oauthAuthorizationForProvider(provider) : lease.authorization;
|
|
7028
|
+
const issuedLease = {
|
|
7029
|
+
provider,
|
|
7030
|
+
grant,
|
|
7031
|
+
...lease.account ? { account: lease.account } : void 0,
|
|
7032
|
+
...authorization ? { authorization } : void 0,
|
|
7033
|
+
expiresAt: lease.expiresAt,
|
|
7034
|
+
headerTransforms
|
|
7035
|
+
};
|
|
7036
|
+
assertLeaseTransformsOwnedByProvider(provider, issuedLease);
|
|
7037
|
+
await setSandboxEgressCredentialLease(context, issuedLease);
|
|
7038
|
+
return issuedLease;
|
|
7039
|
+
}
|
|
7040
|
+
/** Return whether a credential lease can modify requests to the target host. */
|
|
7041
|
+
function hasSandboxEgressLeaseTransformForHost(lease, host) {
|
|
7042
|
+
return lease.headerTransforms.some((transform) => matchesSandboxEgressDomain(host, transform.domain));
|
|
7043
|
+
}
|
|
7044
|
+
// src/chat/egress/credentialed.ts
|
|
7045
|
+
const HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
7046
|
+
"connection",
|
|
7047
|
+
"host",
|
|
7048
|
+
"keep-alive",
|
|
7049
|
+
"proxy-authenticate",
|
|
7050
|
+
"proxy-authorization",
|
|
7051
|
+
"te",
|
|
7052
|
+
"trailer",
|
|
7053
|
+
"transfer-encoding",
|
|
7054
|
+
"upgrade"
|
|
7055
|
+
]);
|
|
7056
|
+
const PROXY_ONLY_HEADERS = /* @__PURE__ */ new Set([
|
|
7057
|
+
"vercel-sandbox-oidc-token",
|
|
7058
|
+
"vercel-forwarded-host",
|
|
7059
|
+
"vercel-forwarded-scheme",
|
|
7060
|
+
"vercel-forwarded-port",
|
|
7061
|
+
"vercel-forwarded-path"
|
|
7062
|
+
]);
|
|
7063
|
+
const TRACE_PROPAGATION_HEADERS = /* @__PURE__ */ new Set([
|
|
7064
|
+
"baggage",
|
|
7065
|
+
"sentry-trace",
|
|
7066
|
+
"traceparent"
|
|
7067
|
+
]);
|
|
7068
|
+
const DECODED_RESPONSE_HEADERS = /* @__PURE__ */ new Set(["content-encoding", "content-length"]);
|
|
7069
|
+
const UPSTREAM_TOKEN_REJECTION_STATUS = 401;
|
|
7070
|
+
const UPSTREAM_PERMISSION_REJECTION_STATUS = 403;
|
|
7071
|
+
const GRANT_SELECTION_BODY_TEXT_LIMIT_BYTES = 65536;
|
|
7072
|
+
const RESPONSE_BODY_TEXT_LIMIT_BYTES = 65536;
|
|
7073
|
+
function authRequiredResponse(input) {
|
|
7074
|
+
return new Response(`junior-auth-required provider=${input.provider} grant=${input.grant.name} access=${input.grant.access} 401 unauthorized\n${input.message}`, {
|
|
7075
|
+
status: 401,
|
|
7076
|
+
headers: {
|
|
7077
|
+
"content-type": "text/plain; charset=utf-8",
|
|
7078
|
+
"cache-control": "no-store"
|
|
7079
|
+
}
|
|
7080
|
+
});
|
|
7081
|
+
}
|
|
7082
|
+
function policyDeniedResponse(error) {
|
|
7083
|
+
return Response.json({ error: error.message }, {
|
|
7084
|
+
status: 403,
|
|
7085
|
+
headers: { "cache-control": "no-store" }
|
|
7086
|
+
});
|
|
7087
|
+
}
|
|
7088
|
+
function shouldLogSandboxEgressInfo() {
|
|
7089
|
+
return (process.env.SENTRY_ENVIRONMENT ?? process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "").trim().toLowerCase() !== "production";
|
|
7090
|
+
}
|
|
7091
|
+
function egressAttributes(input) {
|
|
7092
|
+
return {
|
|
7093
|
+
...input.egressId ? { "app.sandbox.egress_id": input.egressId } : void 0,
|
|
7094
|
+
...input.provider ? { "app.provider.name": input.provider } : void 0,
|
|
7095
|
+
...input.grantName ? { "app.grant.name": input.grantName } : void 0,
|
|
7096
|
+
...input.grantAccess ? { "app.grant.access": input.grantAccess } : void 0,
|
|
7097
|
+
...input.grantReason ? { "app.grant.reason": input.grantReason } : void 0,
|
|
7098
|
+
...input.host ? { "server.address": input.host } : void 0,
|
|
7099
|
+
...input.method ? { "http.request.method": input.method } : void 0,
|
|
7100
|
+
...input.path ? { "url.path": input.path } : void 0,
|
|
7101
|
+
...input.status ? { "http.response.status_code": input.status } : void 0
|
|
7102
|
+
};
|
|
7103
|
+
}
|
|
7104
|
+
function redactedProxyPath(pathname) {
|
|
7105
|
+
if (pathname.startsWith(`/api/internal/sandbox-egress/`)) return `${SANDBOX_EGRESS_PROXY_PATH}/<token>`;
|
|
7106
|
+
return pathname;
|
|
7107
|
+
}
|
|
7108
|
+
function routingAttributes(request, upstreamUrl) {
|
|
7109
|
+
const attributes = { "app.sandbox.egress.proxy_path": redactedProxyPath(new URL(request.url).pathname) };
|
|
7110
|
+
if (upstreamUrl) {
|
|
7111
|
+
attributes["app.sandbox.egress.upstream_path"] = upstreamUrl.pathname;
|
|
7112
|
+
const gitService = upstreamUrl.searchParams.get("service");
|
|
7113
|
+
if (upstreamUrl.hostname.toLowerCase() === "github.com" && (gitService === "git-upload-pack" || gitService === "git-receive-pack")) attributes["app.sandbox.egress.git_service"] = gitService;
|
|
7114
|
+
}
|
|
7115
|
+
return attributes;
|
|
7116
|
+
}
|
|
7117
|
+
function displayedUpstreamPath(upstreamUrl) {
|
|
7118
|
+
const gitService = upstreamUrl.searchParams.get("service");
|
|
7119
|
+
if (upstreamUrl.hostname.toLowerCase() === "github.com" && (gitService === "git-upload-pack" || gitService === "git-receive-pack")) return `${upstreamUrl.pathname}?service=${gitService}`;
|
|
7120
|
+
return upstreamUrl.pathname;
|
|
7121
|
+
}
|
|
7122
|
+
function permissionDeniedMessage(provider, grant) {
|
|
7123
|
+
return `${provider} returned HTTP 403 after the runtime injected the ${grant.name} grant. The request was forwarded; this is not a local runtime block.`;
|
|
7124
|
+
}
|
|
7125
|
+
function isEgressAuthRequired(error) {
|
|
7126
|
+
return error instanceof EgressAuthRequired || error instanceof Error && error.name === "EgressAuthRequired";
|
|
7127
|
+
}
|
|
7128
|
+
function isEgressPolicyDenied(error) {
|
|
7129
|
+
return error instanceof EgressPolicyDenied || error instanceof Error && error.name === "EgressPolicyDenied";
|
|
7130
|
+
}
|
|
7131
|
+
/**
|
|
7132
|
+
* Record one forwarded provider request. Write-grant requests are audit
|
|
7133
|
+
* events, so they reach Sentry in production. Other requests log only outside
|
|
7134
|
+
* production.
|
|
7135
|
+
*/
|
|
7136
|
+
function logSandboxEgressUpstreamRequest(input) {
|
|
7137
|
+
const isWrite = input.grantAccess === "write";
|
|
7138
|
+
if (!isWrite && !shouldLogSandboxEgressInfo()) return;
|
|
7139
|
+
(isWrite ? logAudit : logInfo)("sandbox.egress.upstream.requested", {
|
|
7140
|
+
...egressAttributes({
|
|
7141
|
+
egressId: input.egressId,
|
|
7142
|
+
grantAccess: input.grantAccess,
|
|
7143
|
+
grantName: input.grantName,
|
|
7144
|
+
grantReason: input.grantReason,
|
|
7145
|
+
host: input.upstreamUrl.hostname,
|
|
7146
|
+
method: input.request.method,
|
|
7147
|
+
path: input.upstreamUrl.pathname,
|
|
7148
|
+
provider: input.provider,
|
|
7149
|
+
status: input.upstream.status
|
|
7150
|
+
}),
|
|
7151
|
+
...routingAttributes(input.request, input.upstreamUrl),
|
|
7152
|
+
"app.sandbox.egress.upstream_ok": input.upstream.ok
|
|
7153
|
+
});
|
|
7154
|
+
}
|
|
7155
|
+
async function requestBodyBytes(request) {
|
|
7156
|
+
if (request.method === "GET" || request.method === "HEAD" || request.method === "OPTIONS" || request.body === null) return;
|
|
7157
|
+
return await request.arrayBuffer();
|
|
7158
|
+
}
|
|
7159
|
+
/**
|
|
7160
|
+
* Explain a request body that the runtime delivered already used.
|
|
7161
|
+
*
|
|
7162
|
+
* The forwarded headers cannot show whether the body was empty, so the proxy
|
|
7163
|
+
* does not send a credentialed write upstream without its payload.
|
|
7164
|
+
*/
|
|
7165
|
+
function unreadableBodyResponse(eventId) {
|
|
7166
|
+
return Response.json({
|
|
7167
|
+
source: "junior-egress",
|
|
7168
|
+
error: "The sandbox egress proxy could not read the request body, so it did not send the request upstream.",
|
|
7169
|
+
...eventId ? { sentryEventId: eventId } : void 0
|
|
7170
|
+
}, {
|
|
7171
|
+
status: 500,
|
|
7172
|
+
headers: { "cache-control": "no-store" }
|
|
7173
|
+
});
|
|
7174
|
+
}
|
|
7175
|
+
/** Identify GitHub writes whose body determines whether a grant is safe. */
|
|
7176
|
+
function githubBodyInspection(input) {
|
|
7177
|
+
if (input.provider !== "github" || input.requestMethod.toUpperCase() !== "POST" || input.upstreamUrl.hostname.toLowerCase() !== "api.github.com") return;
|
|
7178
|
+
const pathname = input.upstreamUrl.pathname.toLowerCase();
|
|
7179
|
+
if (pathname.endsWith("/graphql")) return "graphql";
|
|
7180
|
+
return /^\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/reviews(?:\/[^/]+\/events)?$/.test(pathname) ? "pull-request-review" : void 0;
|
|
7181
|
+
}
|
|
7182
|
+
function grantSelectionBodyTooLargeMessage(inspection) {
|
|
7183
|
+
return inspection === "graphql" ? "GitHub GraphQL request body is too large to inspect before issuing credentials." : "GitHub pull request review request body is too large to inspect before issuing credentials.";
|
|
7184
|
+
}
|
|
7185
|
+
function grantSelectionBodyText(input) {
|
|
7186
|
+
if (input.body === void 0) return;
|
|
7187
|
+
if (input.body.byteLength > GRANT_SELECTION_BODY_TEXT_LIMIT_BYTES) {
|
|
7188
|
+
const inspection = githubBodyInspection({
|
|
7189
|
+
provider: input.provider,
|
|
7190
|
+
requestMethod: input.request.method,
|
|
7191
|
+
upstreamUrl: input.upstreamUrl
|
|
7192
|
+
});
|
|
7193
|
+
if (inspection) throw new EgressPolicyDenied(grantSelectionBodyTooLargeMessage(inspection));
|
|
7194
|
+
return;
|
|
7195
|
+
}
|
|
7196
|
+
return new TextDecoder().decode(input.body);
|
|
7197
|
+
}
|
|
7198
|
+
function responseContentLength(upstream) {
|
|
7199
|
+
const raw = upstream.headers.get("content-length");
|
|
7200
|
+
if (!raw) return;
|
|
7201
|
+
const parsed = Number(raw);
|
|
7202
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : void 0;
|
|
7203
|
+
}
|
|
7204
|
+
async function responseTextWithinLimit(upstream, maxBytes) {
|
|
7205
|
+
const limit = Math.min(Math.max(0, Math.floor(maxBytes)), RESPONSE_BODY_TEXT_LIMIT_BYTES);
|
|
7206
|
+
if (limit <= 0) return;
|
|
7207
|
+
const contentLength = responseContentLength(upstream);
|
|
7208
|
+
if (contentLength !== void 0 && contentLength > limit) return;
|
|
7209
|
+
let clone;
|
|
7210
|
+
try {
|
|
7211
|
+
clone = upstream.clone();
|
|
7212
|
+
} catch {
|
|
7213
|
+
return;
|
|
7214
|
+
}
|
|
7215
|
+
const body = clone.body;
|
|
7216
|
+
if (!body) return "";
|
|
7217
|
+
const reader = body.getReader();
|
|
7218
|
+
const chunks = [];
|
|
7219
|
+
let bytes = 0;
|
|
7220
|
+
try {
|
|
7221
|
+
while (true) {
|
|
7222
|
+
const { done, value } = await reader.read();
|
|
7223
|
+
if (done) break;
|
|
7224
|
+
if (!value) continue;
|
|
7225
|
+
bytes += value.byteLength;
|
|
7226
|
+
if (bytes > limit) {
|
|
7227
|
+
await reader.cancel().catch(() => void 0);
|
|
7228
|
+
return;
|
|
7229
|
+
}
|
|
7230
|
+
chunks.push(value);
|
|
7231
|
+
}
|
|
7232
|
+
} catch {
|
|
7233
|
+
await reader.cancel().catch(() => void 0);
|
|
7234
|
+
return;
|
|
7235
|
+
} finally {
|
|
7236
|
+
reader.releaseLock();
|
|
7237
|
+
}
|
|
7238
|
+
const combined = new Uint8Array(bytes);
|
|
7239
|
+
let offset = 0;
|
|
7240
|
+
for (const chunk of chunks) {
|
|
7241
|
+
combined.set(chunk, offset);
|
|
7242
|
+
offset += chunk.byteLength;
|
|
7243
|
+
}
|
|
7244
|
+
return new TextDecoder().decode(combined);
|
|
7245
|
+
}
|
|
7246
|
+
/**
|
|
7247
|
+
* Build the upstream request headers.
|
|
7248
|
+
*
|
|
7249
|
+
* Sandbox/proxy-only headers are stripped so they cannot leak to providers, and
|
|
7250
|
+
* trace headers are preserved only for hosts opted into trace propagation.
|
|
7251
|
+
* Credential lease transforms win last because they are the host-issued
|
|
7252
|
+
* authority for provider auth headers.
|
|
7253
|
+
*/
|
|
7254
|
+
function requestHeaders(request, lease, upstreamHost, tracePropagation) {
|
|
7255
|
+
const headers = new Headers();
|
|
7256
|
+
const mayPropagateTrace = shouldPropagateSandboxEgressTrace(upstreamHost, tracePropagation);
|
|
7257
|
+
request.headers.forEach((value, key) => {
|
|
7258
|
+
const normalized = key.toLowerCase();
|
|
7259
|
+
if (HOP_BY_HOP_HEADERS.has(normalized) || PROXY_ONLY_HEADERS.has(normalized)) return;
|
|
7260
|
+
if (TRACE_PROPAGATION_HEADERS.has(normalized) && !mayPropagateTrace) return;
|
|
7261
|
+
headers.append(key, value);
|
|
7262
|
+
});
|
|
7263
|
+
for (const transform of lease.headerTransforms) {
|
|
7264
|
+
if (!matchesSandboxEgressDomain(upstreamHost, transform.domain)) continue;
|
|
7265
|
+
for (const [key, value] of Object.entries(transform.headers)) {
|
|
7266
|
+
if (TRACE_PROPAGATION_HEADERS.has(key.toLowerCase()) && !mayPropagateTrace) continue;
|
|
7267
|
+
headers.set(key, value);
|
|
7268
|
+
}
|
|
7269
|
+
}
|
|
7270
|
+
return headers;
|
|
7271
|
+
}
|
|
7272
|
+
function responseHeaders(upstream) {
|
|
7273
|
+
const headers = new Headers();
|
|
7274
|
+
upstream.headers.forEach((value, key) => {
|
|
7275
|
+
const normalized = key.toLowerCase();
|
|
7276
|
+
if (!HOP_BY_HOP_HEADERS.has(normalized) && !DECODED_RESPONSE_HEADERS.has(normalized)) headers.append(key, value);
|
|
7277
|
+
});
|
|
7278
|
+
return headers;
|
|
7279
|
+
}
|
|
7280
|
+
function leaseLogAttributes(input) {
|
|
7281
|
+
return {
|
|
7282
|
+
...egressAttributes({
|
|
7283
|
+
egressId: input.egressId,
|
|
7284
|
+
grantAccess: input.lease.grant.access,
|
|
7285
|
+
grantName: input.lease.grant.name,
|
|
7286
|
+
grantReason: input.lease.grant.reason,
|
|
7287
|
+
host: input.upstreamUrl.hostname,
|
|
7288
|
+
method: input.request.method,
|
|
7289
|
+
path: input.upstreamUrl.pathname,
|
|
7290
|
+
provider: input.provider,
|
|
7291
|
+
status: input.status
|
|
7292
|
+
}),
|
|
7293
|
+
...routingAttributes(input.request, input.upstreamUrl)
|
|
7294
|
+
};
|
|
7295
|
+
}
|
|
7296
|
+
async function recordSandboxAuthRequired(input) {
|
|
7297
|
+
await setSandboxEgressAuthRequiredSignal(input.credentialContext, {
|
|
7298
|
+
provider: input.provider,
|
|
7299
|
+
grant: input.grant,
|
|
7300
|
+
kind: input.kind ?? "auth_required",
|
|
7301
|
+
...input.authorization ? { authorization: input.authorization } : void 0,
|
|
7302
|
+
message: input.message
|
|
7303
|
+
});
|
|
7304
|
+
}
|
|
7305
|
+
async function recordSandboxPermissionDenied(input) {
|
|
7306
|
+
await setSandboxEgressPermissionDeniedSignal(input.credentialContext, {
|
|
7307
|
+
provider: input.provider,
|
|
7308
|
+
grant: input.lease.grant,
|
|
7309
|
+
...input.lease.account ? { account: input.lease.account } : void 0,
|
|
7310
|
+
message: input.message,
|
|
7311
|
+
source: "upstream",
|
|
7312
|
+
status: input.upstream.status,
|
|
7313
|
+
upstreamHost: input.upstreamUrl.hostname,
|
|
7314
|
+
upstreamPath: displayedUpstreamPath(input.upstreamUrl)
|
|
7315
|
+
});
|
|
7316
|
+
}
|
|
7317
|
+
/**
|
|
7318
|
+
* Forward one verified sandbox egress request with host-managed credentials.
|
|
7319
|
+
*
|
|
7320
|
+
* The caller must already have authenticated the sandbox VM, checked the signed
|
|
7321
|
+
* credential context, and resolved the provider for `upstreamUrl`. This function
|
|
7322
|
+
* then selects the read/write grant, issues or reuses a short-lived credential
|
|
7323
|
+
* lease, applies its header transforms, and maps provider auth failures into
|
|
7324
|
+
* command-level signals instead of throwing raw upstream details at the agent.
|
|
7325
|
+
*/
|
|
7326
|
+
async function executeCredentialedEgressRequest(input) {
|
|
7327
|
+
const { activeEgressId, credentialContext, deps, operation, provider, request, upstreamUrl } = input;
|
|
7328
|
+
let body;
|
|
7329
|
+
try {
|
|
7330
|
+
body = await requestBodyBytes(request);
|
|
7331
|
+
} catch (error) {
|
|
7332
|
+
if (!(error instanceof TypeError)) throw error;
|
|
7333
|
+
return unreadableBodyResponse(logException(error, "sandbox.egress.request_body.unreadable", {
|
|
7334
|
+
...egressAttributes({
|
|
7335
|
+
egressId: activeEgressId,
|
|
7336
|
+
host: upstreamUrl.hostname,
|
|
7337
|
+
method: request.method,
|
|
7338
|
+
path: upstreamUrl.pathname,
|
|
7339
|
+
provider,
|
|
7340
|
+
status: 500
|
|
7341
|
+
}),
|
|
7342
|
+
...routingAttributes(request, upstreamUrl)
|
|
7343
|
+
}));
|
|
7344
|
+
}
|
|
7345
|
+
const bodyForGrantSelection = githubBodyInspection({
|
|
7346
|
+
provider,
|
|
7347
|
+
requestMethod: request.method,
|
|
7348
|
+
upstreamUrl
|
|
7349
|
+
}) ? body : void 0;
|
|
7350
|
+
let grantSelection;
|
|
7351
|
+
try {
|
|
7352
|
+
grantSelection = await selectSandboxEgressGrant({
|
|
7353
|
+
bodyText: grantSelectionBodyText({
|
|
7354
|
+
body: bodyForGrantSelection,
|
|
7355
|
+
provider,
|
|
7356
|
+
request,
|
|
7357
|
+
upstreamUrl
|
|
7358
|
+
}),
|
|
7359
|
+
...operation ? { operation } : void 0,
|
|
7360
|
+
provider,
|
|
7361
|
+
method: request.method,
|
|
7362
|
+
upstreamUrl
|
|
7363
|
+
});
|
|
7364
|
+
} catch (error) {
|
|
7365
|
+
if (isEgressPolicyDenied(error)) {
|
|
7366
|
+
logWarn("sandbox.egress.policy.denied", {
|
|
7367
|
+
...egressAttributes({
|
|
7368
|
+
egressId: activeEgressId,
|
|
7369
|
+
host: upstreamUrl.hostname,
|
|
7370
|
+
method: request.method,
|
|
7371
|
+
path: upstreamUrl.pathname,
|
|
7372
|
+
provider,
|
|
7373
|
+
status: 403
|
|
7374
|
+
}),
|
|
7375
|
+
...routingAttributes(request, upstreamUrl),
|
|
7376
|
+
"app.sandbox.egress.policy.reason": error.message
|
|
7377
|
+
});
|
|
7378
|
+
return policyDeniedResponse(error);
|
|
7379
|
+
}
|
|
7380
|
+
throw error;
|
|
7381
|
+
}
|
|
7382
|
+
const issueCredentialLease = deps.issueCredentialLease ?? sandboxEgressCredentialLease;
|
|
7383
|
+
const clearCredentialLease = deps.clearCredentialLease ?? clearSandboxEgressCredentialLease;
|
|
7384
|
+
const recordAuthRequired = deps.recordAuthRequired ?? recordSandboxAuthRequired;
|
|
7385
|
+
const recordPermissionDenied = deps.recordPermissionDenied ?? recordSandboxPermissionDenied;
|
|
7386
|
+
async function resolveLease() {
|
|
7387
|
+
try {
|
|
7388
|
+
return await issueCredentialLease(provider, grantSelection, credentialContext);
|
|
7389
|
+
} catch (error) {
|
|
7390
|
+
if (!(error instanceof SandboxEgressCredentialError)) throw error;
|
|
7391
|
+
await recordAuthRequired({
|
|
7392
|
+
credentialContext,
|
|
7393
|
+
provider: error.provider,
|
|
7394
|
+
grant: error.grant,
|
|
7395
|
+
kind: error.kind,
|
|
7396
|
+
authorization: error.authorization,
|
|
7397
|
+
message: error.message
|
|
7398
|
+
});
|
|
7399
|
+
const isAuthRequired = error.kind === "auth_required";
|
|
7400
|
+
logWarn(isAuthRequired ? "sandbox.egress.credential.needed" : "sandbox.egress.credential.unavailable", {
|
|
7401
|
+
...egressAttributes({
|
|
7402
|
+
egressId: activeEgressId,
|
|
7403
|
+
grantAccess: error.grant.access,
|
|
7404
|
+
grantName: error.grant.name,
|
|
7405
|
+
grantReason: error.grant.reason,
|
|
7406
|
+
host: upstreamUrl.hostname,
|
|
7407
|
+
method: request.method,
|
|
7408
|
+
path: upstreamUrl.pathname,
|
|
7409
|
+
provider: error.provider,
|
|
7410
|
+
status: 401
|
|
7411
|
+
}),
|
|
7412
|
+
...routingAttributes(request, upstreamUrl)
|
|
7413
|
+
});
|
|
7414
|
+
return authRequiredResponse({
|
|
7415
|
+
provider: error.provider,
|
|
7416
|
+
grant: error.grant,
|
|
7417
|
+
message: error.message
|
|
7418
|
+
});
|
|
7419
|
+
}
|
|
7420
|
+
}
|
|
7421
|
+
let leaseOrResponse = await resolveLease();
|
|
7422
|
+
if (leaseOrResponse instanceof Response) return leaseOrResponse;
|
|
7423
|
+
let lease = leaseOrResponse;
|
|
7424
|
+
const attributes = (activeLease, status) => leaseLogAttributes({
|
|
7425
|
+
egressId: activeEgressId,
|
|
7426
|
+
lease: activeLease,
|
|
7427
|
+
provider,
|
|
7428
|
+
request,
|
|
7429
|
+
status,
|
|
7430
|
+
upstreamUrl
|
|
7431
|
+
});
|
|
7432
|
+
if (!hasSandboxEgressLeaseTransformForHost(lease, upstreamUrl.hostname)) {
|
|
7433
|
+
logWarn("sandbox.egress.transform.missing", {
|
|
7434
|
+
...attributes(lease, 403),
|
|
7435
|
+
"app.sandbox.egress.transform_domains": lease.headerTransforms.map((transform) => transform.domain)
|
|
7436
|
+
});
|
|
7437
|
+
return Response.json({ error: "Credential lease does not cover forwarded host" }, { status: 403 });
|
|
7438
|
+
}
|
|
7439
|
+
const fetchImpl = deps.fetch ?? fetch;
|
|
7440
|
+
let retriedAfter403 = false;
|
|
7441
|
+
while (true) {
|
|
7442
|
+
const headers = requestHeaders(request, lease, upstreamUrl.hostname, deps.tracePropagation ?? {});
|
|
7443
|
+
const intercepted = await deps.interceptHttp?.({
|
|
7444
|
+
provider,
|
|
7445
|
+
request: new Request(upstreamUrl, {
|
|
7446
|
+
method: request.method,
|
|
7447
|
+
headers,
|
|
7448
|
+
...body !== void 0 ? { body } : void 0
|
|
7449
|
+
}),
|
|
7450
|
+
upstreamUrl
|
|
7451
|
+
});
|
|
7452
|
+
if (intercepted) return intercepted;
|
|
7453
|
+
const requestBody = body instanceof ArrayBuffer ? body.slice(0) : body;
|
|
7454
|
+
const upstream = await fetchImpl(upstreamUrl, {
|
|
7455
|
+
method: request.method,
|
|
7456
|
+
headers,
|
|
7457
|
+
...requestBody !== void 0 ? { body: requestBody } : void 0,
|
|
7458
|
+
redirect: "manual"
|
|
7459
|
+
});
|
|
7460
|
+
let pluginPermissionDenied;
|
|
7461
|
+
try {
|
|
7462
|
+
pluginPermissionDenied = (await onPluginEgressResponse({
|
|
7463
|
+
provider,
|
|
7464
|
+
grant: lease.grant,
|
|
7465
|
+
method: request.method,
|
|
7466
|
+
...operation ? { operation } : void 0,
|
|
7467
|
+
upstreamUrl,
|
|
7468
|
+
response: {
|
|
7469
|
+
headers: new Headers(upstream.headers),
|
|
7470
|
+
readText: async (maxBytes) => await responseTextWithinLimit(upstream, maxBytes),
|
|
7471
|
+
status: upstream.status
|
|
7472
|
+
}
|
|
7473
|
+
})).permissionDenied;
|
|
7474
|
+
} catch (error) {
|
|
7475
|
+
if (!isEgressAuthRequired(error)) throw error;
|
|
7476
|
+
await clearCredentialLease(provider, lease.grant, credentialContext);
|
|
7477
|
+
await recordAuthRequired({
|
|
7478
|
+
credentialContext,
|
|
7479
|
+
provider,
|
|
7480
|
+
grant: lease.grant,
|
|
7481
|
+
authorization: error.authorization ?? lease.authorization,
|
|
7482
|
+
message: error.message
|
|
7483
|
+
});
|
|
7484
|
+
logWarn("sandbox.egress.upstream_auth_requirement.classified", { ...attributes(lease, upstream.status) });
|
|
7485
|
+
await upstream.body?.cancel().catch(() => void 0);
|
|
7486
|
+
return authRequiredResponse({
|
|
7487
|
+
provider,
|
|
7488
|
+
grant: lease.grant,
|
|
7489
|
+
message: error.message
|
|
7490
|
+
});
|
|
7491
|
+
}
|
|
7492
|
+
logSandboxEgressUpstreamRequest({
|
|
7493
|
+
egressId: activeEgressId,
|
|
7494
|
+
grantAccess: lease.grant.access,
|
|
7495
|
+
grantName: lease.grant.name,
|
|
7496
|
+
grantReason: lease.grant.reason,
|
|
7497
|
+
provider,
|
|
7498
|
+
request,
|
|
7499
|
+
upstream,
|
|
7500
|
+
upstreamUrl
|
|
7501
|
+
});
|
|
7502
|
+
if (upstream.status >= 400) logWarn("sandbox.egress.upstream_response.failed", {
|
|
7503
|
+
...attributes(lease, upstream.status),
|
|
7504
|
+
"error.type": `http_${upstream.status}`
|
|
7505
|
+
});
|
|
7506
|
+
if (upstream.status === UPSTREAM_TOKEN_REJECTION_STATUS) {
|
|
7507
|
+
logWarn("sandbox.egress.upstream_auth.rejected", {
|
|
7508
|
+
...attributes(lease, upstream.status),
|
|
7509
|
+
"app.sandbox.egress.www_authenticate": upstream.headers.get("www-authenticate") ?? void 0
|
|
7510
|
+
});
|
|
7511
|
+
await clearCredentialLease(provider, lease.grant, credentialContext);
|
|
7512
|
+
await recordAuthRequired({
|
|
7513
|
+
credentialContext,
|
|
7514
|
+
provider,
|
|
7515
|
+
grant: lease.grant,
|
|
7516
|
+
authorization: lease.authorization,
|
|
7517
|
+
message: `Provider rejected the injected ${provider} credential.`
|
|
7518
|
+
});
|
|
7519
|
+
await upstream.body?.cancel().catch(() => void 0);
|
|
7520
|
+
return authRequiredResponse({
|
|
7521
|
+
provider,
|
|
7522
|
+
grant: lease.grant,
|
|
7523
|
+
message: `Provider rejected the injected ${provider} credential.\n`
|
|
7524
|
+
});
|
|
7525
|
+
}
|
|
7526
|
+
if (upstream.status === UPSTREAM_PERMISSION_REJECTION_STATUS && !retriedAfter403) {
|
|
7527
|
+
logWarn("sandbox.egress.upstream_auth.rejected", { ...attributes(lease, upstream.status) });
|
|
7528
|
+
await clearCredentialLease(provider, lease.grant, credentialContext);
|
|
7529
|
+
await upstream.body?.cancel().catch(() => void 0);
|
|
7530
|
+
logWarn("sandbox.egress.upstream_auth.retrying", { ...attributes(lease, upstream.status) });
|
|
7531
|
+
leaseOrResponse = await resolveLease();
|
|
7532
|
+
if (leaseOrResponse instanceof Response) return leaseOrResponse;
|
|
7533
|
+
lease = leaseOrResponse;
|
|
7534
|
+
retriedAfter403 = true;
|
|
7535
|
+
continue;
|
|
7536
|
+
}
|
|
7537
|
+
if (upstream.status === UPSTREAM_PERMISSION_REJECTION_STATUS) {
|
|
7538
|
+
logWarn("sandbox.egress.upstream_auth.rejected", { ...attributes(lease, upstream.status) });
|
|
7539
|
+
await clearCredentialLease(provider, lease.grant, credentialContext);
|
|
7540
|
+
await recordPermissionDenied({
|
|
7541
|
+
credentialContext,
|
|
7542
|
+
provider,
|
|
7543
|
+
lease,
|
|
7544
|
+
message: pluginPermissionDenied?.message ?? permissionDeniedMessage(provider, lease.grant),
|
|
7545
|
+
upstream,
|
|
7546
|
+
upstreamUrl
|
|
7547
|
+
});
|
|
7548
|
+
if (pluginPermissionDenied) logWarn("sandbox.egress.upstream_permission.classified", { ...attributes(lease, upstream.status) });
|
|
7549
|
+
return new Response(upstream.body, {
|
|
7550
|
+
status: upstream.status,
|
|
7551
|
+
statusText: upstream.statusText,
|
|
7552
|
+
headers: responseHeaders(upstream)
|
|
7553
|
+
});
|
|
7554
|
+
}
|
|
7555
|
+
if (pluginPermissionDenied) {
|
|
7556
|
+
await recordPermissionDenied({
|
|
7557
|
+
credentialContext,
|
|
7558
|
+
provider,
|
|
7559
|
+
lease,
|
|
7560
|
+
message: pluginPermissionDenied.message,
|
|
7561
|
+
upstream,
|
|
7562
|
+
upstreamUrl
|
|
7563
|
+
});
|
|
7564
|
+
logWarn("sandbox.egress.upstream_permission.classified", { ...attributes(lease, upstream.status) });
|
|
7565
|
+
}
|
|
7566
|
+
return new Response(upstream.body, {
|
|
7567
|
+
status: upstream.status,
|
|
7568
|
+
statusText: upstream.statusText,
|
|
7569
|
+
headers: responseHeaders(upstream)
|
|
7570
|
+
});
|
|
7571
|
+
}
|
|
7572
|
+
}
|
|
7573
|
+
export { sandboxEgressSignalsResponseSchema as $, defaultTimezone as $n, getHeaderString as $t, setWorkspaceSnapshotBuild as A, createPluginLogger as An, RESOURCE_SUBSCRIPTION_MAX_TTL_MS as At, wrapSandboxSetupError as B, createPluginConversationEvents as Bn, getWatch as Bt, listWorkspaces as C, SANDBOX_WORKSPACE_ROOT as Cn, tryWorkspaceRepoCheckoutPath as Ct, invalidateReadySnapshot as D, createPluginState as Dn, isSlackMessageTurn as Dt, WorkspaceValidationError as E, sandboxSkillPathResolution as En, getSlackToolContext as Et, ensureDockerDaemon as F, conversationHasParticipantUser as Fn, collectEventMatchKeys as Ft, isVercelSandboxEgressRequest as G, matchSlackAttachments as Gn, bindSlackDirectCredentialSubject as Gt, getWorkspaceSnapshotNotReadyError as H, readConversationEventPage as Hn, takeDueTimerWatches as Ht, getSandboxProvider as I, conversationIsMemberRoot as In, createTimerWatch as It, consumeSandboxEgressAuthRequiredSignal as J, resolveRootVisibility as Jn, verifySlackDirectCredentialSubject as Jt, proxyVercelSandboxEgressRequest as K, readAttachmentVisionSummary as Kn, verifyEventAutomationCredentialSubject as Kt, isAbortError as L, conversationNotArchivedForEmail as Ln, createWatch as Lt, create as M, conversationArchiveTimes as Mn, STOP_WATCHING_TOOL_NAME as Mt, hash as N, conversationArchivedForEmail as Nn, cancelSubscriptions as Nt, loadSnapshotsForProfile as O, createPluginEmbedder as On, slackMessageTurnDisabledReason as Ot, isStale as P, conversationArchivedForUser as Pn, cancelWatch as Pt, parseSandboxEgressAuthRequiredSignal as Q, listConversationAnnotationsById as Qn, getFilePermalink as Qt, isAlreadyExistsError as R, conversationNotArchivedForUser as Rn, deliverWatch as Rt, getWorkspaceByName as S, SANDBOX_SKILLS_ROOT as Sn, slackExternalRefIdSchema as St, updateWorkspace as T, sandboxSkillFile as Tn, resolveChannelCapabilities as Tt, isWorkspaceSnapshotNotReadyError as U, buildSentryConversationUrl as Un, bindEventAutomationCredentialSubject as Ut, WorkspaceSnapshotNotReadyError as V, readLatestConversationBrief as Vn, listWatches as Vt, workspaceSnapshotNotReadyUserMessage as W, getSlackFileId as Wn, bindScheduledAutomationCredentialSubject as Wt, createSandboxEgressCredentialToken as X, createPluginAnnotations as Xn, SlackActionError as Xt, consumeSandboxEgressPermissionDeniedSignal as Y, saveObjectAnnotations as Yn, verifyTimerWatchCredentialSubject as Yt, parseSandboxEgressCredentialToken as Z, listConversationAnnotations as Zn, downloadPrivateSlackFile as Zt, registration_exports as _, buildNonInteractiveShellScript as _n, openSlackDirectMessage as _t, agent_hooks_exports as a, withSlackRetries as an, agentsInstructionsUpdatedEvent as ar, formatProviderLabel as at, deleteWorkspace as b, SANDBOX_DATA_ROOT as bn, uploadFilesToConversation as bt, getPluginRoutes as c, runWithWorkspaceTeamId as cn, conversationNoteEvent as cr, startOAuthFlow as ct, getPluginTools as d, getDashboardObjectIconLink as dn, normalizeSlackReplyMarkdown as dt, getSlackClient as en, resolveUserTimezone as er, buildSandboxEgressNetworkPolicy as et, getPluginUserPromptContributions as f, getDashboardTaskLink as fn, normalizeSlackStatusText as ft, coreTaskRegistrations as g, buildCommandScript as gn, getSlackMessagePermalink as gt, validatePlugins as h, PluginToolDeniedError as hn, addReactionToMessage as ht, resolvePluginOAuthAccount as i, runWithSlackInstallationToken as in, JUNIOR_NATIVE_EVENT_NAMESPACE as ir, deliverOAuthAuthorization as it, SANDBOX_RUNTIME as j, readConversationAccessFromSql as jn, RESOURCE_WATCH_TOOL_SOURCE as jt, setWorkspaceSnapshot as k, createPluginModel as kn, RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS as kt, getPluginSlackConversationLink as l, JUNIOR_THREAD_STATE_TTL_MS as ln, escapeSlackMrkdwnText as lt, setPlugins as m, ToolInputError as mn, truncateStatusText as mt, createUserTokenStore as n, isDmChannel as nn, createPluginCache as nr, resolveSandboxEgressProviderForHost as nt, createPluginHookRunner as o, sleep$1 as on, authenticationLinkedEvent as or, parseOAuthStatePayload as ot, getPlugins as p, setDashboardConversationLinkOptions as pn, slackMention as pt, clearSandboxEgressSignals as q, recordSlackAttachment as qn, verifyScheduledAutomationCredentialSubject as qt, getOAuthAccountProviders as r, normalizeSlackConversationId as rn, createCache as rr, normalizeSandboxEgressTracePropagationDomains as rt, getPluginApiRoutes as s, getWorkspaceTeamId as sn, authenticationUnlinkedEvent as sr, resolveBaseUrl as st, executeCredentialedEgressRequest as t, isConversationScopedChannel as tn, lookupSlackUser as tr, resolveSandboxCommandEnvironment as tt, getPluginSystemPromptContributions as u, getDashboardConversationLink as un, formatSlackLink as ut, setBriefsConfig as v, runNonInteractiveCommand as vn, postSlackMessage as vt, listWorkspacesByRepository as w, sandboxSkillDir as wn, workspaceRepoCheckoutPath as wt, getWorkspace as x, SANDBOX_REPOS_ROOT as xn, slackEntitySchema as xt, createWorkspace as y, SANDBOX_COMMAND_PATH as yn, removeReactionFromMessage as yt, throwSandboxOperationError as z, viewerConversationMembership as zn, findMatchingWatches as zt };
|