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