@sentry/junior 0.265.0 → 0.267.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/dist/{actor-tlxzqaRe.js → actor-DZSokpwj.js} +8 -1
- package/dist/{adapter-cUkPH3Om.js → adapter-BybMEezj.js} +2 -2
- package/dist/{admin-Cf0rDmWV.js → admin-DOs1_EiW.js} +1 -1
- package/dist/{agent-runner-C6MwHJEs.js → agent-runner-DgNATN4D.js} +874 -228
- package/dist/api/schema.d.ts +202 -23
- package/dist/api/schema.js +6 -5
- package/dist/api.d.ts +6 -1
- package/dist/api.js +130 -1205
- package/dist/{app-Csbi9dW7.d.ts → app-DAB3fzyp.d.ts} +21 -3
- package/dist/app.d.ts +2 -2
- package/dist/app.js +1611 -353
- package/dist/{assistant-reply-UPA8vKlC.js → assistant-reply-CT7VcXQe.js} +1 -1
- package/dist/{automation-2RbKzpMT.js → automation-CmeFBSEh.js} +42 -14
- package/dist/{briefs-DRjx8kQ9.js → briefs-CWKjka7v.js} +5 -5
- package/dist/{catalog-runtime-D3qbJlYz.js → catalog-runtime-cMlAI5LY.js} +1 -1
- package/dist/cli/chat.js +15 -15
- package/dist/cli/check.js +3 -3
- package/dist/cli/init.js +0 -1
- package/dist/cli/main.js +3 -3
- package/dist/cli/plugins.js +4 -4
- package/dist/cli/snapshot-create.js +4 -4
- package/dist/cli/upgrade.d.ts +1 -1
- package/dist/cli/upgrade.js +3 -3
- package/dist/{client-DX34VNtH.js → client-BjgbMawj.js} +2 -2
- package/dist/{config-BayQ9hHH.js → config-CdB6yJj5.js} +32 -10
- package/dist/{connections-mpN5Lsnj.js → connections-RE_65jzK.js} +13 -12
- package/dist/{conversation-B0baEwoA.js → conversation-DSo310Ps.js} +14 -3
- package/dist/{conversation-privacy-frjQCJa9.js → conversation-privacy-BzgJC5k0.js} +1 -1
- package/dist/{credential-sync-CZmyDNdR.js → credential-sync-DhEGEpgq.js} +15 -15
- package/dist/{credentialed-C6pKlB1p.js → credentialed-CIGwoLU2.js} +2913 -1026
- package/dist/{db-BnRqQ7G5.d.ts → db-Bg-zC3r5.d.ts} +1 -1
- package/dist/{executor-CAr7xQr2.js → executor-VYrx_K4w.js} +7 -4
- package/dist/{history-AY8rs-FR.js → history-C84mlAwO.js} +2 -1
- package/dist/{images-B2yA3HWE.js → images-CecjXAAz.js} +3 -3
- package/dist/instrumentation.js +6 -2
- package/dist/{job-queue-Cjo6Yg_b.js → job-queue-C0BZSa7r.js} +128 -3
- package/dist/{web-input-Y6aXvmfa.js → list-1XQP-o6K.js} +1374 -71
- package/dist/{logging-D2QBriUo.js → logging-DuZ1A4ab.js} +11 -3
- package/dist/{message-C9ymnpBS.js → message-NKSbbnA6.js} +4 -4
- package/dist/nitro.d.ts +1 -1
- package/dist/nitro.js +3 -3
- package/dist/{oauth-callback-server-BtMf3TPZ.js → oauth-callback-server-qAHLPtml.js} +2 -2
- package/dist/{prompt-CvaDdSX3.js → prompt-BW9jFsqZ.js} +1 -1
- package/dist/{registry-COTeZ2cV.js → registry-FQutO926.js} +2 -2
- package/dist/{resolve-g9PKz9pH.js → resolve-Bk4yeuHy.js} +3 -3
- package/dist/{result-COnC42GR.js → result-B9e9ATOU.js} +9 -9
- package/dist/{runner-BnTNS7x_.js → runner-C3S2ObH-.js} +11 -11
- package/dist/{sandbox-egress-signals-C_IIdLFk.js → sandbox-egress-signals-DqpGzzMB.js} +1 -1
- package/dist/{schema-D5Amlzkp.js → schema-X7Fj4fv1.js} +3 -2
- package/dist/{service-account-CwvToidT.js → service-account-BQasl-qq.js} +3 -3
- package/dist/{service-account-YdYftwV2.js → service-account-Do30HUNv.js} +3 -3
- package/dist/{session-stop-CpCUy1ZJ.js → session-stop-DkOjt46_.js} +45 -128
- package/dist/{skills-Bb2KaJ3m.js → skills-CQHrwfOa.js} +2 -2
- package/dist/{spawn-mbTcrLb3.js → spawn-BHuqN14f.js} +2 -2
- package/dist/{store-CqRnaZSq.js → store-6EetVP-m.js} +3 -3
- package/dist/{store-CG0VJyye.d.ts → store-B8CABbmR.d.ts} +10 -2
- package/dist/store-DvNZIsCr.js +295 -0
- package/dist/{task-queue-8XjDIORF.js → task-queue-B0DLgd2P.js} +10 -5
- package/dist/{task-runner-6mLmG6g_.js → task-runner-B-PD_JOj.js} +26 -14
- package/dist/{tasks-DGbrur6x.js → tasks-0ilmuCux.js} +1440 -46
- package/dist/{thread-state-D45lz-oC.js → thread-state-DRmdJ2QN.js} +5 -5
- package/dist/{turn-failure-response-CaEPLSBn.js → turn-failure-response-G11cs2gE.js} +9 -9
- package/dist/{turn-lifecycle-C35IyRGp.js → turn-lifecycle-OzLIwvi1.js} +2 -2
- package/dist/{turn-session-routing-BKx6rfVd.js → turn-session-routing-Dyut9l7Y.js} +1 -1
- package/dist/user-input-request-BDFBdF8r.js +116 -0
- package/dist/{validation-C0X3CX51.js → validation-CxEmt1id.js} +1 -1
- package/dist/{version-BPHgJwnm.js → version-Bapedn4A.js} +1 -1
- package/dist/version.js +1 -1
- package/dist/{viewer-B2wXNspU.js → viewer-DrN3oC2X.js} +22 -11
- package/dist/{work-DMbqK0kT.js → work-DUQoNcqP.js} +52 -51
- package/dist/{worker-B-rDJ3XN.js → worker-D1l4Ugk0.js} +30 -19
- package/migrations/0064_any_resource_event_triggers.sql +2 -0
- package/migrations/meta/0064_snapshot.json +3505 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +20 -5
- package/dist/store-DdT74Lvq.js +0 -1424
|
@@ -1,28 +1,36 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-nwUcYXjP.js";
|
|
2
|
-
import { C as toOptionalTrimmed, i as completeObject, o as embedTexts } from "./client-
|
|
3
|
-
import { i as getChatConfig, n as botConfig, o as getSlackBotToken, v as normalizeSlackEmojiName } from "./config-
|
|
4
|
-
import {
|
|
2
|
+
import { C as toOptionalTrimmed, i as completeObject, o as embedTexts } from "./client-BjgbMawj.js";
|
|
3
|
+
import { i as getChatConfig, n as botConfig, o as getSlackBotToken, v as normalizeSlackEmojiName } from "./config-CdB6yJj5.js";
|
|
4
|
+
import { B as isRecord$2, E as withSpan, a as extractGenAiUsageSummary, b as setSpanAttributes, c as getLogContextAttributes, d as logAudit, h as logWarn, m as logInfo, p as logException, y as setSentryUser } from "./logging-DuZ1A4ab.js";
|
|
5
5
|
import { a as getClient$1, n as captureMessage, r as continueTrace } from "./sentry-MDkYAVPV.js";
|
|
6
|
-
import { t as canExposeConversationPayload } from "./conversation-privacy-
|
|
6
|
+
import { t as canExposeConversationPayload } from "./conversation-privacy-BzgJC5k0.js";
|
|
7
7
|
import { r as defaultModelId } from "./model-profile-Bg8EoCKD.js";
|
|
8
|
-
import { d as parseSlackTeamId, f as parseSlackUserId,
|
|
9
|
-
import {
|
|
8
|
+
import { d as parseSlackTeamId, f as parseSlackUserId, i as isActorUserId, o as parseActorUserId, p as parseSlackMessageTs, u as parseSlackChannelReferenceId } from "./actor-DZSokpwj.js";
|
|
9
|
+
import { B as parseDestination, I as saveUserTimezone, P as readUserTimezone, a as resolveViewerUser, i as readIdentityById, k as hasAgentTurnUsage, m as getSqlExecutor, p as getDb, r as readActorIdentity, u as getConversationEventStore, v as createSqlStore } from "./viewer-DrN3oC2X.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 { _ as CredentialUnavailableError, a as resolveAuthTokenPlaceholder, h as credentialSubjectSchema, m as credentialLogContext, o as resolvePluginCommandEnv, p as credentialContextSchema } from "./registry-
|
|
14
|
-
import { n as pluginCatalogRuntime } from "./catalog-runtime-
|
|
15
|
-
import {
|
|
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-VYrx_K4w.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-FQutO926.js";
|
|
14
|
+
import { n as pluginCatalogRuntime } from "./catalog-runtime-cMlAI5LY.js";
|
|
15
|
+
import { c as withLock, i as getStateAdapter, o as acquireActiveLock, r as getDefaultRedisStateAdapterFor } from "./adapter-BybMEezj.js";
|
|
16
16
|
import { i as isValidTimeZone } from "./cadence-R7Em4RjE.js";
|
|
17
|
-
import {
|
|
18
|
-
import { n as briefEntriesFromReportEvents, s as generateBrief, t as BRIEF_PROMPT } from "./prompt-
|
|
17
|
+
import { C as conversationReportEventSchema, Q as parseBriefInput, W as readMessageAttachments, Z as conversationBriefSchema, w as conversationReportMessageEventSchema, z as readMessageCardRefs } from "./conversation-DSo310Ps.js";
|
|
18
|
+
import { n as briefEntriesFromReportEvents, s as generateBrief, t as BRIEF_PROMPT } from "./prompt-BW9jFsqZ.js";
|
|
19
|
+
import path from "node:path";
|
|
19
20
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
21
|
import { z } from "zod";
|
|
21
|
-
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
22
|
+
import { createHash, createHmac, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, randomUUID, sign, timingSafeEqual } from "node:crypto";
|
|
22
23
|
import { and, arrayContains, asc, desc, eq, exists, getTableColumns, gte, inArray, isNotNull, isNull, lt, lte, max, ne, notExists, or, sql } from "drizzle-orm";
|
|
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
|
+
import { EgressAuthRequired, EgressPolicyDenied, actorSchema, codeChangeInputSchema, conversationAnnotationInputSchema, conversationEventPresentationSchema, defineConversationEvent, eventDataSchema, 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
25
|
import { alias, union } from "drizzle-orm/pg-core";
|
|
26
|
+
import fs from "node:fs/promises";
|
|
25
27
|
import { WebClient } from "@slack/web-api";
|
|
28
|
+
import http from "node:http";
|
|
29
|
+
import os from "node:os";
|
|
30
|
+
import dns from "node:dns/promises";
|
|
31
|
+
import net from "node:net";
|
|
32
|
+
import { Duplex, Readable } from "node:stream";
|
|
33
|
+
import tls from "node:tls";
|
|
26
34
|
import { FileSystem, Sandbox } from "@vercel/sandbox";
|
|
27
35
|
import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
|
|
28
36
|
// src/chat/conversations/structured-events.ts
|
|
@@ -794,6 +802,12 @@ function reportMessageEvents(meta) {
|
|
|
794
802
|
const parsed = storedMessageEventsSchema.safeParse(meta?.events);
|
|
795
803
|
return parsed.success ? { events: parsed.data } : void 0;
|
|
796
804
|
}
|
|
805
|
+
const TURN_COMPLETED_STATES = {
|
|
806
|
+
success: "succeeded",
|
|
807
|
+
no_reply: "no_reply",
|
|
808
|
+
cancelled: "no_reply",
|
|
809
|
+
blocked: "blocked"
|
|
810
|
+
};
|
|
797
811
|
function reportEventData(args) {
|
|
798
812
|
const { data } = args;
|
|
799
813
|
if (data.type === "structured_event") {
|
|
@@ -879,7 +893,7 @@ function reportEventData(args) {
|
|
|
879
893
|
case "turn_completed": return {
|
|
880
894
|
type: "turn_lifecycle",
|
|
881
895
|
turnId: data.turnId,
|
|
882
|
-
state: data.outcome
|
|
896
|
+
state: TURN_COMPLETED_STATES[data.outcome]
|
|
883
897
|
};
|
|
884
898
|
case "turn_failed": {
|
|
885
899
|
const eventId = data.eventId;
|
|
@@ -1211,7 +1225,7 @@ async function briefInputFromSql(executor, conversationId, throughSeq) {
|
|
|
1211
1225
|
});
|
|
1212
1226
|
}
|
|
1213
1227
|
// src/chat/briefs/operational-report.ts
|
|
1214
|
-
const DAY_MS$
|
|
1228
|
+
const DAY_MS$2 = 864e5;
|
|
1215
1229
|
const WINDOWS = [
|
|
1216
1230
|
7,
|
|
1217
1231
|
30,
|
|
@@ -1241,8 +1255,8 @@ function formatUsd(value) {
|
|
|
1241
1255
|
*/
|
|
1242
1256
|
async function buildBriefsOperationalReport(args) {
|
|
1243
1257
|
const newestDay = startOfUtcDay$1(args.nowMs);
|
|
1244
|
-
const windowStart = /* @__PURE__ */ new Date(newestDay.getTime() - 29 * DAY_MS$
|
|
1245
|
-
const windowEnd = new Date(newestDay.getTime() + DAY_MS$
|
|
1258
|
+
const windowStart = /* @__PURE__ */ new Date(newestDay.getTime() - 29 * DAY_MS$2);
|
|
1259
|
+
const windowEnd = new Date(newestDay.getTime() + DAY_MS$2);
|
|
1246
1260
|
const [[counts], recentCounts] = await Promise.all([args.db.select({
|
|
1247
1261
|
conversations: sql`count(distinct ${juniorConversationBriefs.conversationId})`.mapWith(Number),
|
|
1248
1262
|
stored: sql`count(*)`.mapWith(Number)
|
|
@@ -1632,7 +1646,7 @@ function createPluginConversationEventReader(plugin) {
|
|
|
1632
1646
|
} };
|
|
1633
1647
|
}
|
|
1634
1648
|
// src/chat/plugins/conversation-event-stats.ts
|
|
1635
|
-
const DAY_MS = 864e5;
|
|
1649
|
+
const DAY_MS$1 = 864e5;
|
|
1636
1650
|
const costRowSchema = z.object({
|
|
1637
1651
|
costUsd: z.number().finite().nonnegative(),
|
|
1638
1652
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2})?$/),
|
|
@@ -1684,8 +1698,8 @@ function createPluginConversationEventStats(plugin, now = Date.now) {
|
|
|
1684
1698
|
async costsByDay(input) {
|
|
1685
1699
|
const eventName = registeredEventName(plugin, input.eventName);
|
|
1686
1700
|
const end = startOfUtcDay(now());
|
|
1687
|
-
const start = /* @__PURE__ */ new Date(end.getTime() - (input.days - 1) * DAY_MS);
|
|
1688
|
-
const endExclusive = new Date(end.getTime() + DAY_MS);
|
|
1701
|
+
const start = /* @__PURE__ */ new Date(end.getTime() - (input.days - 1) * DAY_MS$1);
|
|
1702
|
+
const endExclusive = new Date(end.getTime() + DAY_MS$1);
|
|
1689
1703
|
const date = sql`TO_CHAR(
|
|
1690
1704
|
${juniorConversationEvents.createdAt} AT TIME ZONE 'UTC',
|
|
1691
1705
|
'YYYY-MM-DD'
|
|
@@ -1702,7 +1716,7 @@ function createPluginConversationEventStats(plugin, now = Date.now) {
|
|
|
1702
1716
|
}).from(juniorConversationEvents).where(and(eq(juniorConversationEvents.type, "structured_event"), sql`${juniorConversationEvents.payload}->>'namespace' = ${plugin.manifest.name}`, sql`${juniorConversationEvents.payload}->>'name' = ${eventName}`, gte(juniorConversationEvents.createdAt, start), lt(juniorConversationEvents.createdAt, endExclusive))).groupBy(date);
|
|
1703
1717
|
const byDate = new Map(z.array(costRowSchema).parse(rows).map((row) => [row.date, row]));
|
|
1704
1718
|
return Array.from({ length: input.days }, (_, index) => {
|
|
1705
|
-
const date = new Date(start.getTime() + index * DAY_MS).toISOString().slice(0, 10);
|
|
1719
|
+
const date = new Date(start.getTime() + index * DAY_MS$1).toISOString().slice(0, 10);
|
|
1706
1720
|
return byDate.get(date) ?? {
|
|
1707
1721
|
costUsd: 0,
|
|
1708
1722
|
date,
|
|
@@ -1844,13 +1858,15 @@ const SANDBOX_WORKSPACE_ROOT = normalizeWorkspaceRoot(process.env.VERCEL_SANDBOX
|
|
|
1844
1858
|
const SANDBOX_REPOS_ROOT = `${SANDBOX_WORKSPACE_ROOT}/repos`;
|
|
1845
1859
|
const SANDBOX_SKILLS_ROOT = `${SANDBOX_WORKSPACE_ROOT}/skills`;
|
|
1846
1860
|
const SANDBOX_DATA_ROOT = `${SANDBOX_WORKSPACE_ROOT}/data`;
|
|
1861
|
+
/** Runtime-owned files inside the sandbox, outside every repository. */
|
|
1862
|
+
const SANDBOX_JUNIOR_ROOT = `${SANDBOX_WORKSPACE_ROOT}/.junior`;
|
|
1847
1863
|
/**
|
|
1848
1864
|
* PATH for sandbox commands. Plugin runtime binaries come last so they never
|
|
1849
1865
|
* shadow repo or Workspace tools with the same name. Repo tools often start
|
|
1850
1866
|
* child processes through PATH, so only PATH order can protect them. Plugin
|
|
1851
1867
|
* binaries stay reachable by name or by absolute path.
|
|
1852
1868
|
*/
|
|
1853
|
-
const SANDBOX_COMMAND_PATH = `$PATH:${`${
|
|
1869
|
+
const SANDBOX_COMMAND_PATH = `$PATH:${`${SANDBOX_JUNIOR_ROOT}/bin`}`;
|
|
1854
1870
|
function sandboxSkillDir(skillName) {
|
|
1855
1871
|
return `${SANDBOX_SKILLS_ROOT}/${skillName}`;
|
|
1856
1872
|
}
|
|
@@ -1877,21 +1893,44 @@ const NON_INTERACTIVE_ENV = {
|
|
|
1877
1893
|
GCM_INTERACTIVE: "never",
|
|
1878
1894
|
DEBIAN_FRONTEND: "noninteractive",
|
|
1879
1895
|
GIT_ASKPASS: "/bin/true",
|
|
1880
|
-
GIT_CONFIG_NOSYSTEM: "1"
|
|
1881
|
-
GIT_CONFIG_COUNT: "2",
|
|
1882
|
-
GIT_CONFIG_KEY_0: "credential.helper",
|
|
1883
|
-
GIT_CONFIG_VALUE_0: "",
|
|
1884
|
-
GIT_CONFIG_KEY_1: "http.emptyAuth",
|
|
1885
|
-
GIT_CONFIG_VALUE_1: "true"
|
|
1896
|
+
GIT_CONFIG_NOSYSTEM: "1"
|
|
1886
1897
|
};
|
|
1898
|
+
/** Git config the runtime always applies through `GIT_CONFIG_COUNT`. */
|
|
1899
|
+
const RUNTIME_GIT_CONFIG = [["credential.helper", ""], ["http.emptyAuth", "true"]];
|
|
1900
|
+
/**
|
|
1901
|
+
* Combine command Git config env entries with the runtime entries.
|
|
1902
|
+
*
|
|
1903
|
+
* Plugin hooks can add Git config with `GIT_CONFIG_COUNT`. Their entries keep
|
|
1904
|
+
* their order, and the runtime entries come last so credential isolation
|
|
1905
|
+
* always wins.
|
|
1906
|
+
*/
|
|
1907
|
+
function gitConfigEnv(env) {
|
|
1908
|
+
const count = Number.parseInt(env.GIT_CONFIG_COUNT ?? "0", 10) || 0;
|
|
1909
|
+
const entries = [];
|
|
1910
|
+
for (let index = 0; index < count; index++) {
|
|
1911
|
+
const key = env[`GIT_CONFIG_KEY_${index}`];
|
|
1912
|
+
if (key) entries.push([key, env[`GIT_CONFIG_VALUE_${index}`] ?? ""]);
|
|
1913
|
+
}
|
|
1914
|
+
entries.push(...RUNTIME_GIT_CONFIG);
|
|
1915
|
+
const result = { GIT_CONFIG_COUNT: String(entries.length) };
|
|
1916
|
+
entries.forEach(([key, value], index) => {
|
|
1917
|
+
result[`GIT_CONFIG_KEY_${index}`] = key;
|
|
1918
|
+
result[`GIT_CONFIG_VALUE_${index}`] = value;
|
|
1919
|
+
});
|
|
1920
|
+
return result;
|
|
1921
|
+
}
|
|
1887
1922
|
function shellQuote(value) {
|
|
1888
1923
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
1889
1924
|
}
|
|
1890
1925
|
function buildEnvExports(options) {
|
|
1891
1926
|
const lines = [];
|
|
1892
1927
|
if (options.path) lines.push(`export PATH="${options.path}"`);
|
|
1893
|
-
|
|
1894
|
-
|
|
1928
|
+
const env = {
|
|
1929
|
+
...NON_INTERACTIVE_ENV,
|
|
1930
|
+
...options.env,
|
|
1931
|
+
...gitConfigEnv(options.env ?? {})
|
|
1932
|
+
};
|
|
1933
|
+
for (const [key, value] of Object.entries(env)) lines.push(`export ${key}=${shellQuote(value)}`);
|
|
1895
1934
|
return lines;
|
|
1896
1935
|
}
|
|
1897
1936
|
/** Render one argv command as a safely quoted shell script. */
|
|
@@ -3072,849 +3111,1112 @@ function workspaceRepoCheckoutPath(repo) {
|
|
|
3072
3111
|
if (!path) throw new Error(`Invalid repository name for checkout path: ${repo}`);
|
|
3073
3112
|
return path;
|
|
3074
3113
|
}
|
|
3075
|
-
// src/chat/sandbox/
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3114
|
+
// src/chat/sandbox/docker/api.ts
|
|
3115
|
+
const API_VERSION = "v1.43";
|
|
3116
|
+
const DEFAULT_SOCKET_PATH = "/var/run/docker.sock";
|
|
3117
|
+
/** Error response from the Docker Engine API. */
|
|
3118
|
+
var DockerApiError = class extends Error {
|
|
3119
|
+
statusCode;
|
|
3120
|
+
constructor(statusCode, message) {
|
|
3121
|
+
super(`Docker API error status=${statusCode}: ${message}`);
|
|
3122
|
+
this.name = "DockerApiError";
|
|
3123
|
+
this.statusCode = statusCode;
|
|
3124
|
+
}
|
|
3125
|
+
};
|
|
3126
|
+
/** Return the unix socket path of the Docker daemon from `DOCKER_HOST`. */
|
|
3127
|
+
function dockerSocketPath() {
|
|
3128
|
+
const host = process.env.DOCKER_HOST?.trim();
|
|
3129
|
+
if (!host) return DEFAULT_SOCKET_PATH;
|
|
3130
|
+
if (!host.startsWith("unix://")) throw new Error(`The Docker sandbox provider supports only unix sockets. DOCKER_HOST is ${host}`);
|
|
3131
|
+
return host.slice(7);
|
|
3132
|
+
}
|
|
3133
|
+
function requestPath(input) {
|
|
3134
|
+
const params = new URLSearchParams();
|
|
3135
|
+
for (const [key, value] of Object.entries(input.query ?? {})) if (value !== void 0) params.set(key, String(value));
|
|
3136
|
+
const query = params.toString();
|
|
3137
|
+
return `/${API_VERSION}${input.path}${query ? `?${query}` : ""}`;
|
|
3138
|
+
}
|
|
3139
|
+
/** Send one request and return the response stream with its status. */
|
|
3140
|
+
function dockerStream(input) {
|
|
3141
|
+
input.signal?.throwIfAborted();
|
|
3142
|
+
const raw = input.body instanceof Uint8Array ? Buffer.from(input.body) : void 0;
|
|
3143
|
+
const json = input.body !== void 0 && !raw ? JSON.stringify(input.body) : void 0;
|
|
3144
|
+
return new Promise((resolve, reject) => {
|
|
3145
|
+
const request = http.request({
|
|
3146
|
+
socketPath: dockerSocketPath(),
|
|
3147
|
+
agent: false,
|
|
3148
|
+
path: requestPath(input),
|
|
3149
|
+
method: input.method ?? "GET",
|
|
3150
|
+
headers: {
|
|
3151
|
+
...json ? { "content-type": "application/json" } : void 0,
|
|
3152
|
+
...raw ? { "content-type": "application/x-tar" } : void 0
|
|
3153
|
+
},
|
|
3154
|
+
signal: input.signal
|
|
3155
|
+
});
|
|
3156
|
+
request.on("response", resolve);
|
|
3157
|
+
request.on("error", reject);
|
|
3158
|
+
request.end(raw ?? json);
|
|
3159
|
+
});
|
|
3160
|
+
}
|
|
3161
|
+
/** Read a whole response stream into one buffer. */
|
|
3162
|
+
async function readStream(stream) {
|
|
3163
|
+
const chunks = [];
|
|
3164
|
+
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
3165
|
+
return Buffer.concat(chunks);
|
|
3166
|
+
}
|
|
3167
|
+
async function errorFrom(response) {
|
|
3168
|
+
const body = (await readStream(response)).toString("utf8");
|
|
3169
|
+
let message = body.trim() || response.statusMessage || "request failed";
|
|
3170
|
+
try {
|
|
3171
|
+
const parsed = JSON.parse(body);
|
|
3172
|
+
if (typeof parsed.message === "string") message = parsed.message;
|
|
3173
|
+
} catch {}
|
|
3174
|
+
throw new DockerApiError(response.statusCode ?? 0, message);
|
|
3175
|
+
}
|
|
3176
|
+
/** Send one request and return its body. Error statuses throw `DockerApiError`. */
|
|
3177
|
+
async function dockerRequest(input) {
|
|
3178
|
+
const response = await dockerStream(input);
|
|
3179
|
+
if ((response.statusCode ?? 0) >= 400) return await errorFrom(response);
|
|
3180
|
+
return await readStream(response);
|
|
3181
|
+
}
|
|
3182
|
+
/** Send one request and parse its JSON body. */
|
|
3183
|
+
async function dockerJson(input) {
|
|
3184
|
+
const body = await dockerRequest(input);
|
|
3185
|
+
return body.length > 0 ? JSON.parse(body.toString("utf8")) : {};
|
|
3186
|
+
}
|
|
3187
|
+
/** Send one request and return the response stream. Error statuses throw. */
|
|
3188
|
+
async function dockerOkStream(input) {
|
|
3189
|
+
const response = await dockerStream(input);
|
|
3190
|
+
if ((response.statusCode ?? 0) >= 400) return await errorFrom(response);
|
|
3191
|
+
return response;
|
|
3192
|
+
}
|
|
3193
|
+
// src/chat/sandbox/docker/exec.ts
|
|
3194
|
+
/** Directory for command state in each sandbox. The runtime image creates it. */
|
|
3195
|
+
const COMMAND_STATE_DIR = "/var/tmp/junior-commands";
|
|
3196
|
+
/** User for commands without `sudo`. It matches Vercel Sandbox. */
|
|
3197
|
+
const SANDBOX_USER = "vercel-sandbox";
|
|
3198
|
+
const KILL_GRACE_MS = 2e3;
|
|
3199
|
+
const EXIT_POLL_MS = 250;
|
|
3200
|
+
function timeoutArgs(timeoutMs) {
|
|
3201
|
+
if (!timeoutMs || timeoutMs <= 0) return [];
|
|
3202
|
+
return [
|
|
3203
|
+
"timeout",
|
|
3204
|
+
"-k",
|
|
3205
|
+
"5",
|
|
3206
|
+
`${Math.ceil(timeoutMs / 1e3)}s`
|
|
3207
|
+
];
|
|
3208
|
+
}
|
|
3209
|
+
function envList(env) {
|
|
3210
|
+
return Object.entries(env ?? {}).map(([key, value]) => `${key}=${value}`);
|
|
3211
|
+
}
|
|
3212
|
+
async function createExec(containerId, input, signal) {
|
|
3213
|
+
return (await dockerJson({
|
|
3214
|
+
method: "POST",
|
|
3215
|
+
path: `/containers/${encodeURIComponent(containerId)}/exec`,
|
|
3216
|
+
body: {
|
|
3217
|
+
AttachStdout: input.attach,
|
|
3218
|
+
AttachStderr: input.attach,
|
|
3219
|
+
Cmd: input.cmd,
|
|
3220
|
+
Env: envList(input.env),
|
|
3221
|
+
User: input.root ? "root" : SANDBOX_USER,
|
|
3222
|
+
...input.cwd ? { WorkingDir: input.cwd } : void 0
|
|
3223
|
+
},
|
|
3224
|
+
signal
|
|
3225
|
+
})).Id;
|
|
3226
|
+
}
|
|
3227
|
+
/** Split Docker's multiplexed exec stream into stdout and stderr. */
|
|
3228
|
+
async function demux(stream) {
|
|
3229
|
+
const stdout = [];
|
|
3230
|
+
const stderr = [];
|
|
3231
|
+
let pending = Buffer.alloc(0);
|
|
3232
|
+
for await (const chunk of stream) {
|
|
3233
|
+
pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
|
3234
|
+
while (pending.length >= 8) {
|
|
3235
|
+
const size = pending.readUInt32BE(4);
|
|
3236
|
+
if (pending.length < 8 + size) break;
|
|
3237
|
+
const frame = pending.subarray(8, 8 + size);
|
|
3238
|
+
if (pending[0] === 2) stderr.push(frame);
|
|
3239
|
+
else stdout.push(frame);
|
|
3240
|
+
pending = pending.subarray(8 + size);
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
return {
|
|
3244
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
3245
|
+
stderr: Buffer.concat(stderr).toString("utf8")
|
|
3088
3246
|
};
|
|
3089
3247
|
}
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
"invalid_blocks",
|
|
3097
|
-
"missing_scope",
|
|
3098
|
-
"missing_charset",
|
|
3099
|
-
"superfluous_charset",
|
|
3100
|
-
"message_truncated"
|
|
3101
|
-
]);
|
|
3102
|
-
const pathFields = /* @__PURE__ */ new Set([
|
|
3103
|
-
"metadata",
|
|
3104
|
-
"event_type",
|
|
3105
|
-
"event_payload",
|
|
3106
|
-
"entities",
|
|
3107
|
-
"entity_type",
|
|
3108
|
-
"entity_payload",
|
|
3109
|
-
"external_ref",
|
|
3110
|
-
"id",
|
|
3111
|
-
"type",
|
|
3112
|
-
"url",
|
|
3113
|
-
"attributes",
|
|
3114
|
-
"title",
|
|
3115
|
-
"text",
|
|
3116
|
-
"display_id",
|
|
3117
|
-
"display_type",
|
|
3118
|
-
"product_name",
|
|
3119
|
-
"fields",
|
|
3120
|
-
"status",
|
|
3121
|
-
"custom_fields",
|
|
3122
|
-
"key",
|
|
3123
|
-
"label",
|
|
3124
|
-
"value",
|
|
3125
|
-
"long",
|
|
3126
|
-
"format",
|
|
3127
|
-
"link",
|
|
3128
|
-
"display_order",
|
|
3129
|
-
"app_unfurl_url",
|
|
3130
|
-
"app_id",
|
|
3131
|
-
"created_by",
|
|
3132
|
-
"created_at",
|
|
3133
|
-
"updated_by",
|
|
3134
|
-
"updated_at",
|
|
3135
|
-
"full_size_preview",
|
|
3136
|
-
"preview_url",
|
|
3137
|
-
"mime_type"
|
|
3138
|
-
]);
|
|
3139
|
-
const diagnosticWords = new Set(`WARN ERROR Message message metadata was incorrectly formatted The the will be
|
|
3140
|
-
ignored as a result For for event entity refer to following errors error
|
|
3141
|
-
missing required field fields property properties additional unexpected unknown
|
|
3142
|
-
unsupported invalid expected received actual type types must should match matches
|
|
3143
|
-
matching does not is are has have of at in on and or an only allowed allow
|
|
3144
|
-
value values string number integer boolean object array null minimum maximum
|
|
3145
|
-
length min max items item empty nonempty too long short large small size limit
|
|
3146
|
-
exceeded exceeds less greater than equal valid format schema validation failed
|
|
3147
|
-
failure cannot contain contains found one any all none enum pattern definition
|
|
3148
|
-
defined constraint constraints satisfy satisfies with without provided specified
|
|
3149
|
-
unrecognized unrecognised recognized recognised extraneous disallowed duplicate
|
|
3150
|
-
unique malformed nested root exactly needs need requires requirement supported
|
|
3151
|
-
json-pointer input different schema_uri schema_id schemas additionalProperties
|
|
3152
|
-
minLength maxLength minItems maxItems anyOf oneOf allOf`.split(/\s+/));
|
|
3153
|
-
function summarizeDiagnostic(value) {
|
|
3154
|
-
return value.slice(0, 2e3).replace(/\b(?:https?:\/\/|www\.)[^\s"'`<>]+/gi, "[value]").replace(/\/[^\s"'`[\],;()]*|[\p{L}\p{N}\p{M}_]+(?:[-./:@=+%~][\p{L}\p{N}\p{M}_]+)*|[^\s\w[\]{}():,;."'`/*=<>!?+-]/gu, (token) => {
|
|
3155
|
-
if (token.startsWith("/")) return token.split("/").slice(0, 17).map((part, index) => index === 0 ? "" : pathFields.has(part) ? part : "*").join("/");
|
|
3156
|
-
return codes.has(token) || pathFields.has(token) || diagnosticWords.has(token) ? token : "[value]";
|
|
3157
|
-
}).replace(/\s+/g, " ").slice(0, 2e3);
|
|
3248
|
+
async function waitForExit(execId) {
|
|
3249
|
+
for (;;) {
|
|
3250
|
+
const inspect = await dockerJson({ path: `/exec/${execId}/json` });
|
|
3251
|
+
if (!inspect.Running) return inspect.ExitCode ?? -1;
|
|
3252
|
+
await sleep$1(EXIT_POLL_MS);
|
|
3253
|
+
}
|
|
3158
3254
|
}
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3255
|
+
async function killGroup(containerId, id, signalName) {
|
|
3256
|
+
const pidFile = `${COMMAND_STATE_DIR}/${id}.pid`;
|
|
3257
|
+
await runRawExec(containerId, {
|
|
3258
|
+
cmd: [
|
|
3259
|
+
"sh",
|
|
3260
|
+
"-c",
|
|
3261
|
+
`[ -f ${pidFile} ] && kill -${signalName} -- -"$(cat ${pidFile})" 2>/dev/null; true`
|
|
3262
|
+
],
|
|
3263
|
+
root: true
|
|
3264
|
+
});
|
|
3265
|
+
}
|
|
3266
|
+
/** Run one exec without the process-group wrapper. Used for internal steps. */
|
|
3267
|
+
async function runRawExec(containerId, input, signal) {
|
|
3268
|
+
const execId = await createExec(containerId, {
|
|
3269
|
+
...input,
|
|
3270
|
+
attach: true
|
|
3271
|
+
}, signal);
|
|
3272
|
+
const output = await demux(await dockerOkStream({
|
|
3273
|
+
method: "POST",
|
|
3274
|
+
path: `/exec/${execId}/start`,
|
|
3275
|
+
body: {
|
|
3276
|
+
Detach: false,
|
|
3277
|
+
Tty: false
|
|
3278
|
+
},
|
|
3279
|
+
signal
|
|
3280
|
+
}));
|
|
3281
|
+
return {
|
|
3282
|
+
exitCode: await waitForExit(execId),
|
|
3283
|
+
...output
|
|
3168
3284
|
};
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
"
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3285
|
+
}
|
|
3286
|
+
/** Run one command in a sandbox container. Abort kills its process group. */
|
|
3287
|
+
async function runSandboxCommand(containerId, input) {
|
|
3288
|
+
input.signal?.throwIfAborted();
|
|
3289
|
+
const id = randomUUID();
|
|
3290
|
+
const pidFile = `${COMMAND_STATE_DIR}/${id}.pid`;
|
|
3291
|
+
const execId = await createExec(containerId, {
|
|
3292
|
+
cmd: [
|
|
3293
|
+
"setsid",
|
|
3294
|
+
"-w",
|
|
3295
|
+
"sh",
|
|
3296
|
+
"-c",
|
|
3297
|
+
`echo $$ > ${pidFile}; exec "$@"`,
|
|
3298
|
+
"sh",
|
|
3299
|
+
...timeoutArgs(input.timeoutMs),
|
|
3300
|
+
input.cmd,
|
|
3301
|
+
...input.args ?? []
|
|
3302
|
+
],
|
|
3303
|
+
cwd: input.cwd,
|
|
3304
|
+
env: input.env,
|
|
3305
|
+
root: input.sudo,
|
|
3306
|
+
attach: true
|
|
3307
|
+
});
|
|
3308
|
+
const stream = await dockerOkStream({
|
|
3309
|
+
method: "POST",
|
|
3310
|
+
path: `/exec/${execId}/start`,
|
|
3311
|
+
body: {
|
|
3312
|
+
Detach: false,
|
|
3313
|
+
Tty: false
|
|
3183
3314
|
}
|
|
3184
3315
|
});
|
|
3316
|
+
const signal = input.signal;
|
|
3317
|
+
const onAbort = () => {
|
|
3318
|
+
(async () => {
|
|
3319
|
+
await killGroup(containerId, id, "TERM");
|
|
3320
|
+
await sleep$1(KILL_GRACE_MS);
|
|
3321
|
+
await killGroup(containerId, id, "KILL");
|
|
3322
|
+
})().catch(() => {});
|
|
3323
|
+
};
|
|
3324
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3325
|
+
try {
|
|
3326
|
+
const output = await demux(stream);
|
|
3327
|
+
signal?.throwIfAborted();
|
|
3328
|
+
return {
|
|
3329
|
+
exitCode: await waitForExit(execId),
|
|
3330
|
+
...output
|
|
3331
|
+
};
|
|
3332
|
+
} finally {
|
|
3333
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3334
|
+
runRawExec(containerId, {
|
|
3335
|
+
cmd: [
|
|
3336
|
+
"rm",
|
|
3337
|
+
"-f",
|
|
3338
|
+
pidFile
|
|
3339
|
+
],
|
|
3340
|
+
root: true
|
|
3341
|
+
}).catch(() => {});
|
|
3342
|
+
}
|
|
3185
3343
|
}
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
const
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
});
|
|
3218
|
-
|
|
3219
|
-
const slackExternalRefIdSchema = z.string().min(1).regex(/^[A-Za-z0-9_-]+$/, "Unsupported Slack external reference ID");
|
|
3220
|
-
const entity = z.strictObject({
|
|
3221
|
-
external_ref: z.strictObject({
|
|
3222
|
-
id: slackExternalRefIdSchema,
|
|
3223
|
-
type: z.string().min(1).optional()
|
|
3224
|
-
}),
|
|
3225
|
-
url
|
|
3226
|
-
});
|
|
3227
|
-
/** Validate notification and detail metadata before Slack can ignore bad fields. */
|
|
3228
|
-
const slackEntitySchema = z.discriminatedUnion("entity_type", [entity.extend({
|
|
3229
|
-
entity_type: z.literal("slack#/entities/item"),
|
|
3230
|
-
entity_payload: payload.extend({ fields: z.never().optional() })
|
|
3231
|
-
}), entity.extend({
|
|
3232
|
-
entity_type: z.literal("slack#/entities/task"),
|
|
3233
|
-
entity_payload: payload.extend({ fields: z.strictObject({ status: z.strictObject({ value: z.string() }).optional() }).optional() })
|
|
3234
|
-
})]);
|
|
3235
|
-
// src/chat/slack/outbound.ts
|
|
3236
|
-
const MAX_SLACK_MESSAGE_TEXT_CHARS = 4e4;
|
|
3237
|
-
function requireSlackConversationId(channelId, action) {
|
|
3238
|
-
const normalized = normalizeSlackConversationId(channelId);
|
|
3239
|
-
if (!normalized) throw new Error(`${action} requires a valid channel ID`);
|
|
3240
|
-
return normalized;
|
|
3344
|
+
/** Start a command that keeps running after this request. Return its id. */
|
|
3345
|
+
async function startDetachedCommand(containerId, input) {
|
|
3346
|
+
const id = randomUUID();
|
|
3347
|
+
const dir = `${COMMAND_STATE_DIR}/${id}`;
|
|
3348
|
+
await dockerJson({
|
|
3349
|
+
method: "POST",
|
|
3350
|
+
path: `/exec/${await createExec(containerId, {
|
|
3351
|
+
cmd: [
|
|
3352
|
+
"sh",
|
|
3353
|
+
"-c",
|
|
3354
|
+
[
|
|
3355
|
+
`mkdir -p ${dir}`,
|
|
3356
|
+
`"$@" >${dir}/stdout 2>${dir}/stderr`,
|
|
3357
|
+
`echo $? >${dir}/exit.tmp`,
|
|
3358
|
+
`mv ${dir}/exit.tmp ${dir}/exit`
|
|
3359
|
+
].join("; "),
|
|
3360
|
+
"sh",
|
|
3361
|
+
...timeoutArgs(input.timeoutMs),
|
|
3362
|
+
input.cmd,
|
|
3363
|
+
...input.args ?? []
|
|
3364
|
+
],
|
|
3365
|
+
cwd: input.cwd,
|
|
3366
|
+
env: input.env,
|
|
3367
|
+
root: input.sudo,
|
|
3368
|
+
attach: false
|
|
3369
|
+
}, input.signal)}/start`,
|
|
3370
|
+
body: {
|
|
3371
|
+
Detach: true,
|
|
3372
|
+
Tty: false
|
|
3373
|
+
},
|
|
3374
|
+
signal: input.signal
|
|
3375
|
+
});
|
|
3376
|
+
return id;
|
|
3241
3377
|
}
|
|
3242
|
-
function
|
|
3243
|
-
const
|
|
3244
|
-
|
|
3245
|
-
|
|
3378
|
+
async function readCommandFile(containerId, id, name, signal) {
|
|
3379
|
+
const result = await runRawExec(containerId, {
|
|
3380
|
+
cmd: ["cat", `${COMMAND_STATE_DIR}/${id}/${name}`],
|
|
3381
|
+
root: true
|
|
3382
|
+
}, signal);
|
|
3383
|
+
return result.exitCode === 0 ? result.stdout : null;
|
|
3246
3384
|
}
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3385
|
+
/**
|
|
3386
|
+
* Wait until a detached command writes its exit code.
|
|
3387
|
+
*
|
|
3388
|
+
* The state lives in files in the container, so any process can wait for a
|
|
3389
|
+
* command that another process started.
|
|
3390
|
+
*/
|
|
3391
|
+
async function waitForDetachedCommand(containerId, id, signal) {
|
|
3392
|
+
for (;;) {
|
|
3393
|
+
signal.throwIfAborted();
|
|
3394
|
+
const exit = await readCommandFile(containerId, id, "exit", signal);
|
|
3395
|
+
if (exit !== null) return {
|
|
3396
|
+
exitCode: Number.parseInt(exit.trim(), 10),
|
|
3397
|
+
stdout: async () => await readCommandFile(containerId, id, "stdout") ?? "",
|
|
3398
|
+
stderr: async () => await readCommandFile(containerId, id, "stderr") ?? ""
|
|
3399
|
+
};
|
|
3400
|
+
await sleep$1(EXIT_POLL_MS, signal);
|
|
3401
|
+
}
|
|
3251
3402
|
}
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3403
|
+
// src/chat/sandbox/docker/settings.ts
|
|
3404
|
+
/** Label on every container, network, and image that this provider owns. */
|
|
3405
|
+
const DOCKER_SANDBOX_LABEL = "dev.junior.sandbox";
|
|
3406
|
+
/**
|
|
3407
|
+
* Directory for machine-wide provider files, such as the egress CA key.
|
|
3408
|
+
* `JUNIOR_DOCKER_SANDBOX_DIR` overrides the default.
|
|
3409
|
+
*/
|
|
3410
|
+
function dockerSandboxDir() {
|
|
3411
|
+
const configured = process.env.JUNIOR_DOCKER_SANDBOX_DIR?.trim();
|
|
3412
|
+
if (configured) return configured;
|
|
3413
|
+
return path.join(os.homedir(), ".cache", "junior", "docker-sandbox");
|
|
3256
3414
|
}
|
|
3257
|
-
/**
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3415
|
+
/**
|
|
3416
|
+
* Optional PEM file with more CA certificates for each sandbox to trust.
|
|
3417
|
+
*
|
|
3418
|
+
* Use this when the Docker host itself sits behind a TLS proxy, such as the
|
|
3419
|
+
* network proxy of a Vercel Sandbox. Without it, sandbox containers cannot
|
|
3420
|
+
* verify the certificates that this proxy shows.
|
|
3421
|
+
*/
|
|
3422
|
+
function dockerSandboxExtraCaFile() {
|
|
3423
|
+
return process.env.JUNIOR_DOCKER_SANDBOX_EXTRA_CA_CERTS?.trim() || void 0;
|
|
3424
|
+
}
|
|
3425
|
+
// src/chat/sandbox/docker/tar.ts
|
|
3426
|
+
const BLOCK = 512;
|
|
3427
|
+
function writeString(header, value, offset, length) {
|
|
3428
|
+
header.write(value, offset, Math.min(Buffer.byteLength(value), length));
|
|
3429
|
+
}
|
|
3430
|
+
function writeOctal(header, value, offset, length) {
|
|
3431
|
+
writeString(header, `${value.toString(8).padStart(length - 1, "0")}\0`, offset, length);
|
|
3432
|
+
}
|
|
3433
|
+
function header(input) {
|
|
3434
|
+
const block = Buffer.alloc(BLOCK);
|
|
3435
|
+
writeString(block, input.name, 0, 100);
|
|
3436
|
+
writeOctal(block, input.mode & 4095, 100, 8);
|
|
3437
|
+
writeOctal(block, input.uid, 108, 8);
|
|
3438
|
+
writeOctal(block, input.gid, 116, 8);
|
|
3439
|
+
writeOctal(block, input.size, 124, 12);
|
|
3440
|
+
writeOctal(block, Math.floor(Date.now() / 1e3), 136, 12);
|
|
3441
|
+
block.fill(" ", 148, 156);
|
|
3442
|
+
writeString(block, input.type, 156, 1);
|
|
3443
|
+
writeString(block, "ustar\0", 257, 6);
|
|
3444
|
+
writeString(block, "00", 263, 2);
|
|
3445
|
+
let checksum = 0;
|
|
3446
|
+
for (const byte of block) checksum += byte;
|
|
3447
|
+
writeString(block, `${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8);
|
|
3448
|
+
return block;
|
|
3449
|
+
}
|
|
3450
|
+
function padding(size) {
|
|
3451
|
+
const remainder = size % BLOCK;
|
|
3452
|
+
return Buffer.alloc(remainder === 0 ? 0 : BLOCK - remainder);
|
|
3453
|
+
}
|
|
3454
|
+
/** A PAX record such as `30 path=some/long/name\n`. */
|
|
3455
|
+
function paxRecord(key, value) {
|
|
3456
|
+
const body = ` ${key}=${value}\n`;
|
|
3457
|
+
let length = Buffer.byteLength(body);
|
|
3458
|
+
length += String(length).length;
|
|
3459
|
+
if (String(length).length !== String(length - 1).length) length += 1;
|
|
3460
|
+
return Buffer.from(`${length}${body}`);
|
|
3461
|
+
}
|
|
3462
|
+
/** Write regular files into one uncompressed tar archive. */
|
|
3463
|
+
function packTar(files) {
|
|
3464
|
+
const parts = [];
|
|
3465
|
+
for (const file of files) {
|
|
3466
|
+
const content = Buffer.from(file.content);
|
|
3467
|
+
let name = file.path;
|
|
3468
|
+
if (Buffer.byteLength(name) > 99) {
|
|
3469
|
+
const pax = paxRecord("path", name);
|
|
3470
|
+
parts.push(header({
|
|
3471
|
+
...file,
|
|
3472
|
+
name: "PaxHeader",
|
|
3473
|
+
size: pax.length,
|
|
3474
|
+
type: "x"
|
|
3475
|
+
}), pax, padding(pax.length));
|
|
3476
|
+
name = name.slice(-99);
|
|
3477
|
+
}
|
|
3478
|
+
parts.push(header({
|
|
3479
|
+
...file,
|
|
3480
|
+
name,
|
|
3481
|
+
size: content.length,
|
|
3482
|
+
type: "0"
|
|
3483
|
+
}), content, padding(content.length));
|
|
3273
3484
|
}
|
|
3485
|
+
parts.push(Buffer.alloc(BLOCK * 2));
|
|
3486
|
+
return Buffer.concat(parts);
|
|
3274
3487
|
}
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
if (!channelId) throw new Error("Slack did not return a direct message channel.");
|
|
3279
|
-
return channelId;
|
|
3488
|
+
function readOctal(block, offset, length) {
|
|
3489
|
+
const text = block.subarray(offset, offset + length).toString("ascii").replace(/\0.*$/s, "").trim();
|
|
3490
|
+
return text ? Number.parseInt(text, 8) : 0;
|
|
3280
3491
|
}
|
|
3281
|
-
/**
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
"
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3492
|
+
/** Return the content of the first regular file in a tar archive. */
|
|
3493
|
+
function unpackFirstFile(archive) {
|
|
3494
|
+
let offset = 0;
|
|
3495
|
+
while (offset + BLOCK <= archive.length) {
|
|
3496
|
+
const block = archive.subarray(offset, offset + BLOCK);
|
|
3497
|
+
if (block.every((byte) => byte === 0)) return null;
|
|
3498
|
+
const size = readOctal(block, 124, 12);
|
|
3499
|
+
const type = String.fromCharCode(block[156] ?? 0);
|
|
3500
|
+
const start = offset + BLOCK;
|
|
3501
|
+
if (type === "0" || type === "\0") return archive.subarray(start, start + size);
|
|
3502
|
+
offset = start + size + padding(size).length;
|
|
3503
|
+
}
|
|
3504
|
+
return null;
|
|
3505
|
+
}
|
|
3506
|
+
// src/chat/sandbox/docker/container.ts
|
|
3507
|
+
const SUBNET_PREFIX = "10.213";
|
|
3508
|
+
const CA_ANCHOR = "/etc/pki/ca-trust/source/anchors/junior-docker-sandbox.pem";
|
|
3509
|
+
/** Amazon Linux's combined CA bundle, rebuilt by `update-ca-trust`. */
|
|
3510
|
+
const SYSTEM_CA_BUNDLE = "/etc/pki/tls/certs/ca-bundle.crt";
|
|
3511
|
+
/** Runtimes that ignore the system trust store but read these env vars. */
|
|
3512
|
+
const CA_BUNDLE_ENV = [
|
|
3513
|
+
"NODE_EXTRA_CA_CERTS",
|
|
3514
|
+
"REQUESTS_CA_BUNDLE",
|
|
3515
|
+
"SSL_CERT_FILE",
|
|
3516
|
+
"CURL_CA_BUNDLE",
|
|
3517
|
+
"AWS_CA_BUNDLE"
|
|
3518
|
+
];
|
|
3519
|
+
const READY_TIMEOUT_MS = 3e4;
|
|
3520
|
+
const READY_POLL_MS = 100;
|
|
3521
|
+
/**
|
|
3522
|
+
* PID 1 script of every sandbox container. `docker-init` runs it.
|
|
3523
|
+
*
|
|
3524
|
+
* It clears state that a snapshot or restart leaves behind, then stops the
|
|
3525
|
+
* container when its deadline passes. `extendTimeout` moves the deadline.
|
|
3526
|
+
* The ready file holds the start time of PID 1, so a check never sees the
|
|
3527
|
+
* ready file of an earlier start.
|
|
3528
|
+
*/
|
|
3529
|
+
const INIT_SCRIPT = `set -u
|
|
3530
|
+
rm -f /var/run/docker.pid /run/junior/ready
|
|
3531
|
+
mkdir -p /run/junior
|
|
3532
|
+
echo $(( $(date +%s) + JUNIOR_SANDBOX_TIMEOUT_SECONDS )) > /run/junior/deadline
|
|
3533
|
+
cut -d' ' -f22 /proc/1/stat > /run/junior/ready
|
|
3534
|
+
trap 'exit 0' TERM INT
|
|
3535
|
+
while :; do
|
|
3536
|
+
deadline=$(cat /run/junior/deadline 2>/dev/null || echo 0)
|
|
3537
|
+
[ "$(date +%s)" -ge "$deadline" ] && exit 0
|
|
3538
|
+
sleep 2 & wait $!
|
|
3539
|
+
done
|
|
3540
|
+
`;
|
|
3541
|
+
function isStatus(error, status) {
|
|
3542
|
+
return error instanceof DockerApiError && error.statusCode === status;
|
|
3543
|
+
}
|
|
3544
|
+
/** Return the name of a sandbox's egress relay container. See `relay.ts`. */
|
|
3545
|
+
function egressRelayName(name) {
|
|
3546
|
+
return `${name}-egress`;
|
|
3547
|
+
}
|
|
3548
|
+
/** Return a sandbox name for a sandbox that the caller did not name. */
|
|
3549
|
+
function newSandboxName() {
|
|
3550
|
+
return `junior-${randomUUID()}`;
|
|
3551
|
+
}
|
|
3552
|
+
async function listSandboxResources(kind) {
|
|
3553
|
+
return await dockerJson({
|
|
3554
|
+
path: kind === "containers" ? "/containers/json" : "/networks",
|
|
3555
|
+
query: {
|
|
3556
|
+
...kind === "containers" ? { all: true } : void 0,
|
|
3557
|
+
filters: JSON.stringify({ label: [DOCKER_SANDBOX_LABEL] })
|
|
3558
|
+
}
|
|
3559
|
+
});
|
|
3560
|
+
}
|
|
3561
|
+
/** Remove sandbox networks whose container no longer exists. */
|
|
3562
|
+
async function pruneNetworks() {
|
|
3563
|
+
const containers = await listSandboxResources("containers");
|
|
3564
|
+
const used = new Set(containers.map((container) => container.Labels[DOCKER_SANDBOX_LABEL]));
|
|
3565
|
+
const networks = await listSandboxResources("networks");
|
|
3566
|
+
for (const network of networks) {
|
|
3567
|
+
if (used.has(network.Labels["dev.junior.sandbox"])) continue;
|
|
3568
|
+
await forceRemove(egressRelayName(network.Name));
|
|
3569
|
+
await removeNetwork(network.Name);
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
async function createNetwork(name, signal) {
|
|
3573
|
+
await pruneNetworks();
|
|
3574
|
+
const networks = await listSandboxResources("networks");
|
|
3575
|
+
const taken = new Set(networks.flatMap((network) => (network.IPAM.Config ?? []).map((config) => config.Subnet)));
|
|
3576
|
+
for (let index = 0; index < 256; index += 1) {
|
|
3577
|
+
const subnet = `${SUBNET_PREFIX}.${index}.0/24`;
|
|
3578
|
+
if (taken.has(subnet)) continue;
|
|
3579
|
+
try {
|
|
3580
|
+
await dockerJson({
|
|
3581
|
+
method: "POST",
|
|
3582
|
+
path: "/networks/create",
|
|
3583
|
+
body: {
|
|
3584
|
+
Name: name,
|
|
3585
|
+
Driver: "bridge",
|
|
3586
|
+
CheckDuplicate: true,
|
|
3587
|
+
IPAM: { Config: [{ Subnet: subnet }] },
|
|
3588
|
+
Labels: { [DOCKER_SANDBOX_LABEL]: name }
|
|
3589
|
+
},
|
|
3590
|
+
signal
|
|
3591
|
+
});
|
|
3592
|
+
return;
|
|
3593
|
+
} catch (error) {
|
|
3594
|
+
if (error instanceof DockerApiError && /overlap/i.test(error.message)) continue;
|
|
3595
|
+
throw error;
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
throw new Error(`No free ${SUBNET_PREFIX}.x.0/24 subnet for sandbox ${name}`);
|
|
3599
|
+
}
|
|
3600
|
+
async function removeNetwork(name) {
|
|
3601
|
+
try {
|
|
3602
|
+
await dockerRequest({
|
|
3603
|
+
method: "DELETE",
|
|
3604
|
+
path: `/networks/${encodeURIComponent(name)}`
|
|
3307
3605
|
});
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3606
|
+
} catch (error) {
|
|
3607
|
+
if (!isStatus(error, 404)) throw error;
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
async function inspect(name, signal) {
|
|
3611
|
+
return await dockerJson({
|
|
3612
|
+
path: `/containers/${encodeURIComponent(name)}/json`,
|
|
3613
|
+
signal
|
|
3312
3614
|
});
|
|
3313
|
-
|
|
3314
|
-
|
|
3615
|
+
}
|
|
3616
|
+
function toContainer(details) {
|
|
3617
|
+
const name = details.Name.replace(/^\//, "");
|
|
3618
|
+
const network = details.NetworkSettings.Networks[name];
|
|
3619
|
+
if (!network?.IPAddress) throw new Error(`Sandbox container ${name} has no address on its network`);
|
|
3315
3620
|
return {
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
}) } : void 0
|
|
3621
|
+
id: details.Id,
|
|
3622
|
+
name,
|
|
3623
|
+
ip: network.IPAddress,
|
|
3624
|
+
gateway: network.Gateway
|
|
3321
3625
|
};
|
|
3322
3626
|
}
|
|
3627
|
+
async function waitUntilReady(id, signal) {
|
|
3628
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
3629
|
+
while (Date.now() < deadline) {
|
|
3630
|
+
if ((await runRawExec(id, {
|
|
3631
|
+
cmd: [
|
|
3632
|
+
"sh",
|
|
3633
|
+
"-c",
|
|
3634
|
+
`[ "$(cat /run/junior/ready 2>/dev/null)" = "$(cut -d' ' -f22 /proc/1/stat)" ]`
|
|
3635
|
+
],
|
|
3636
|
+
root: true
|
|
3637
|
+
}, signal)).exitCode === 0) return;
|
|
3638
|
+
await sleep$1(READY_POLL_MS, signal);
|
|
3639
|
+
}
|
|
3640
|
+
throw new Error(`Sandbox container ${id} did not become ready`);
|
|
3641
|
+
}
|
|
3642
|
+
async function caCertificates(egressCaPem) {
|
|
3643
|
+
const extraFile = dockerSandboxExtraCaFile();
|
|
3644
|
+
const extra = extraFile ? await fs.readFile(extraFile, "utf8") : "";
|
|
3645
|
+
return `${egressCaPem.trim()}\n${extra.trim()}\n`;
|
|
3646
|
+
}
|
|
3647
|
+
/** Put the egress CA into the sandbox's system trust store. */
|
|
3648
|
+
async function trustEgressCa(id, egressCaPem) {
|
|
3649
|
+
await dockerRequest({
|
|
3650
|
+
method: "PUT",
|
|
3651
|
+
path: `/containers/${id}/archive`,
|
|
3652
|
+
query: { path: "/" },
|
|
3653
|
+
body: packTar([{
|
|
3654
|
+
path: `${CA_ANCHOR.slice(1)}.new`,
|
|
3655
|
+
content: Buffer.from(await caCertificates(egressCaPem)),
|
|
3656
|
+
mode: 420,
|
|
3657
|
+
uid: 0,
|
|
3658
|
+
gid: 0
|
|
3659
|
+
}])
|
|
3660
|
+
});
|
|
3661
|
+
const result = await runRawExec(id, {
|
|
3662
|
+
cmd: [
|
|
3663
|
+
"sh",
|
|
3664
|
+
"-c",
|
|
3665
|
+
[
|
|
3666
|
+
"set -eu",
|
|
3667
|
+
`if cmp -s ${CA_ANCHOR}.new ${CA_ANCHOR}; then rm ${CA_ANCHOR}.new; exit 0; fi`,
|
|
3668
|
+
`mv ${CA_ANCHOR}.new ${CA_ANCHOR}`,
|
|
3669
|
+
"update-ca-trust extract"
|
|
3670
|
+
].join("\n")
|
|
3671
|
+
],
|
|
3672
|
+
root: true
|
|
3673
|
+
});
|
|
3674
|
+
if (result.exitCode !== 0) throw new Error(`Could not trust the egress CA: ${result.stderr}`);
|
|
3675
|
+
}
|
|
3676
|
+
async function start(name, signal) {
|
|
3677
|
+
try {
|
|
3678
|
+
await dockerRequest({
|
|
3679
|
+
method: "POST",
|
|
3680
|
+
path: `/containers/${encodeURIComponent(name)}/start`,
|
|
3681
|
+
signal
|
|
3682
|
+
});
|
|
3683
|
+
} catch (error) {
|
|
3684
|
+
if (!isStatus(error, 304)) throw error;
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3323
3687
|
/**
|
|
3324
|
-
*
|
|
3325
|
-
*
|
|
3688
|
+
* Create and start one sandbox container, then wait until it is ready.
|
|
3689
|
+
*
|
|
3690
|
+
* A container that is not persistent removes itself when it stops, like a
|
|
3691
|
+
* Vercel Sandbox that is not persistent.
|
|
3326
3692
|
*/
|
|
3327
|
-
async function
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3693
|
+
async function createContainer(input, egressCaPem) {
|
|
3694
|
+
await createNetwork(input.name, input.signal);
|
|
3695
|
+
try {
|
|
3696
|
+
await dockerJson({
|
|
3697
|
+
method: "POST",
|
|
3698
|
+
path: "/containers/create",
|
|
3699
|
+
query: { name: input.name },
|
|
3700
|
+
body: {
|
|
3701
|
+
Image: input.image,
|
|
3702
|
+
Cmd: [
|
|
3703
|
+
"sh",
|
|
3704
|
+
"-c",
|
|
3705
|
+
INIT_SCRIPT
|
|
3706
|
+
],
|
|
3707
|
+
User: "root",
|
|
3708
|
+
WorkingDir: SANDBOX_WORKSPACE_ROOT,
|
|
3709
|
+
Env: [`JUNIOR_SANDBOX_TIMEOUT_SECONDS=${Math.ceil(input.timeoutMs / 1e3)}`, ...CA_BUNDLE_ENV.map((name) => `${name}=${SYSTEM_CA_BUNDLE}`)],
|
|
3710
|
+
Labels: { [DOCKER_SANDBOX_LABEL]: input.name },
|
|
3711
|
+
HostConfig: {
|
|
3712
|
+
Init: true,
|
|
3713
|
+
Privileged: true,
|
|
3714
|
+
AutoRemove: !input.persistent,
|
|
3715
|
+
NetworkMode: input.name,
|
|
3716
|
+
Mounts: [{
|
|
3717
|
+
Type: "volume",
|
|
3718
|
+
Target: "/var/lib/docker"
|
|
3719
|
+
}],
|
|
3720
|
+
...input.vcpus ? { NanoCpus: input.vcpus * 1e9 } : void 0
|
|
3721
|
+
}
|
|
3722
|
+
},
|
|
3723
|
+
signal: input.signal
|
|
3724
|
+
});
|
|
3725
|
+
} catch (error) {
|
|
3726
|
+
await removeNetwork(input.name).catch(() => void 0);
|
|
3727
|
+
throw error;
|
|
3728
|
+
}
|
|
3729
|
+
try {
|
|
3730
|
+
await start(input.name, input.signal);
|
|
3731
|
+
const details = await inspect(input.name, input.signal);
|
|
3732
|
+
await waitUntilReady(details.Id, input.signal);
|
|
3733
|
+
await trustEgressCa(details.Id, egressCaPem);
|
|
3734
|
+
return toContainer(details);
|
|
3735
|
+
} catch (error) {
|
|
3736
|
+
await removeContainer(input.name).catch(() => void 0);
|
|
3737
|
+
throw error;
|
|
3738
|
+
}
|
|
3347
3739
|
}
|
|
3348
|
-
/**
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
return { files: ((await withSlackRetries(() => getSlackClient().filesUploadV2({
|
|
3362
|
-
channel_id: channelId,
|
|
3363
|
-
...threadTs ? { thread_ts: threadTs } : void 0,
|
|
3364
|
-
file_uploads: fileUploads
|
|
3365
|
-
}), 3, {
|
|
3366
|
-
action: "filesUploadV2",
|
|
3367
|
-
spanAttributes: {
|
|
3368
|
-
"app.slack.channel_id": channelId,
|
|
3369
|
-
...threadTs ? { "app.slack.thread_ts": threadTs } : void 0
|
|
3370
|
-
}
|
|
3371
|
-
})).files ?? []).flatMap((completion) => (completion.files ?? []).map((file) => file.id ? { id: file.id } : {})) };
|
|
3740
|
+
/**
|
|
3741
|
+
* Return a running sandbox container. Start it again when it stopped.
|
|
3742
|
+
* A missing container throws a 404 `DockerApiError`.
|
|
3743
|
+
*/
|
|
3744
|
+
async function getContainer(name, egressCaPem, signal) {
|
|
3745
|
+
let details = await inspect(name, signal);
|
|
3746
|
+
if (!details.State.Running) {
|
|
3747
|
+
await start(name, signal);
|
|
3748
|
+
details = await inspect(name, signal);
|
|
3749
|
+
}
|
|
3750
|
+
await waitUntilReady(details.Id, signal);
|
|
3751
|
+
await trustEgressCa(details.Id, egressCaPem);
|
|
3752
|
+
return toContainer(details);
|
|
3372
3753
|
}
|
|
3373
|
-
|
|
3374
|
-
async function addReactionToMessage(input) {
|
|
3375
|
-
const channelId = requireSlackConversationId(input.channelId, "Slack reaction");
|
|
3376
|
-
const timestamp = requireSlackMessageTimestamp(input.timestamp, "Slack reaction");
|
|
3377
|
-
const emoji = normalizeSlackEmojiName(input.emoji);
|
|
3378
|
-
if (!emoji) throw new Error("Slack reaction requires a valid emoji alias name");
|
|
3754
|
+
async function forceRemove(name) {
|
|
3379
3755
|
try {
|
|
3380
|
-
await
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
idempotent: true,
|
|
3387
|
-
spanAttributes: {
|
|
3388
|
-
"app.slack.channel_id": channelId,
|
|
3389
|
-
"app.slack.message_ts": timestamp,
|
|
3390
|
-
"app.slack.reaction": emoji
|
|
3756
|
+
await dockerRequest({
|
|
3757
|
+
method: "DELETE",
|
|
3758
|
+
path: `/containers/${encodeURIComponent(name)}`,
|
|
3759
|
+
query: {
|
|
3760
|
+
force: true,
|
|
3761
|
+
v: true
|
|
3391
3762
|
}
|
|
3392
3763
|
});
|
|
3393
3764
|
} catch (error) {
|
|
3394
|
-
if (error
|
|
3395
|
-
throw error;
|
|
3765
|
+
if (!isStatus(error, 404) && !isStatus(error, 409)) throw error;
|
|
3396
3766
|
}
|
|
3397
|
-
return { ok: true };
|
|
3398
3767
|
}
|
|
3399
|
-
/**
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3768
|
+
/**
|
|
3769
|
+
* Remove a sandbox container, its inner Docker volume, its egress relay, and
|
|
3770
|
+
* its network.
|
|
3771
|
+
*/
|
|
3772
|
+
async function removeContainer(name) {
|
|
3773
|
+
await forceRemove(name);
|
|
3774
|
+
await forceRemove(egressRelayName(name));
|
|
3775
|
+
await removeNetwork(name);
|
|
3776
|
+
}
|
|
3777
|
+
/**
|
|
3778
|
+
* Stop a sandbox container and remove its egress relay. A container that is
|
|
3779
|
+
* not persistent goes away. The next policy update starts a new relay.
|
|
3780
|
+
*/
|
|
3781
|
+
async function stopContainer(container) {
|
|
3405
3782
|
try {
|
|
3406
|
-
await
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
}), 3, {
|
|
3411
|
-
action: "reactions.remove",
|
|
3412
|
-
idempotent: true,
|
|
3413
|
-
spanAttributes: {
|
|
3414
|
-
"app.slack.channel_id": channelId,
|
|
3415
|
-
"app.slack.message_ts": timestamp,
|
|
3416
|
-
"app.slack.reaction": emoji
|
|
3417
|
-
}
|
|
3783
|
+
await dockerRequest({
|
|
3784
|
+
method: "POST",
|
|
3785
|
+
path: `/containers/${container.id}/stop`,
|
|
3786
|
+
query: { t: 5 }
|
|
3418
3787
|
});
|
|
3419
3788
|
} catch (error) {
|
|
3420
|
-
if (error
|
|
3421
|
-
throw error;
|
|
3789
|
+
if (!isStatus(error, 304) && !isStatus(error, 404)) throw error;
|
|
3422
3790
|
}
|
|
3423
|
-
|
|
3791
|
+
await forceRemove(egressRelayName(container.name));
|
|
3792
|
+
}
|
|
3793
|
+
/** Move the container's stop deadline later by `durationMs`. */
|
|
3794
|
+
async function extendDeadline(id, durationMs) {
|
|
3795
|
+
const result = await runRawExec(id, {
|
|
3796
|
+
cmd: [
|
|
3797
|
+
"sh",
|
|
3798
|
+
"-c",
|
|
3799
|
+
`d=$(cat /run/junior/deadline); echo $((d + ${Math.ceil(durationMs / 1e3)})) > /run/junior/deadline`
|
|
3800
|
+
],
|
|
3801
|
+
root: true
|
|
3802
|
+
});
|
|
3803
|
+
if (result.exitCode !== 0) throw new Error(`Could not extend sandbox timeout: ${result.stderr}`);
|
|
3424
3804
|
}
|
|
3425
|
-
|
|
3426
|
-
const
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3805
|
+
/** The uid and gid of the sandbox user, for files that Junior writes. */
|
|
3806
|
+
const SANDBOX_FILE_OWNER = {
|
|
3807
|
+
uid: 1e3,
|
|
3808
|
+
gid: 1e3,
|
|
3809
|
+
user: SANDBOX_USER
|
|
3810
|
+
};
|
|
3811
|
+
// src/chat/sandbox/docker/certificates.ts
|
|
3812
|
+
const CA_COMMON_NAME = "Junior Docker Sandbox Egress CA";
|
|
3813
|
+
const CA_VALID_DAYS = 3650;
|
|
3814
|
+
/** Clients reject long-lived leaf certificates, so keep them short. */
|
|
3815
|
+
const LEAF_VALID_DAYS = 365;
|
|
3816
|
+
const DAY_MS = 864e5;
|
|
3817
|
+
const OID = {
|
|
3818
|
+
ecdsaWithSha256: "1.2.840.10045.4.3.2",
|
|
3819
|
+
commonName: "2.5.4.3",
|
|
3820
|
+
subjectKeyIdentifier: "2.5.29.14",
|
|
3821
|
+
keyUsage: "2.5.29.15",
|
|
3822
|
+
subjectAltName: "2.5.29.17",
|
|
3823
|
+
basicConstraints: "2.5.29.19",
|
|
3824
|
+
authorityKeyIdentifier: "2.5.29.35",
|
|
3825
|
+
extKeyUsage: "2.5.29.37",
|
|
3826
|
+
serverAuth: "1.3.6.1.5.5.7.3.1",
|
|
3827
|
+
clientAuth: "1.3.6.1.5.5.7.3.2"
|
|
3828
|
+
};
|
|
3829
|
+
function tlv(tag, content) {
|
|
3830
|
+
const length = content.length;
|
|
3831
|
+
if (length < 128) return Buffer.concat([Buffer.from([tag, length]), content]);
|
|
3832
|
+
const bytes = [];
|
|
3833
|
+
for (let value = length; value > 0; value >>= 8) bytes.unshift(value & 255);
|
|
3834
|
+
return Buffer.concat([Buffer.from([
|
|
3835
|
+
tag,
|
|
3836
|
+
128 | bytes.length,
|
|
3837
|
+
...bytes
|
|
3838
|
+
]), content]);
|
|
3839
|
+
}
|
|
3840
|
+
const sequence = (...parts) => tlv(48, Buffer.concat(parts));
|
|
3841
|
+
const set = (...parts) => tlv(49, Buffer.concat(parts));
|
|
3842
|
+
const octetString = (value) => tlv(4, value);
|
|
3843
|
+
const bitString = (value) => tlv(3, Buffer.concat([Buffer.from([0]), value]));
|
|
3844
|
+
const explicit = (index, value) => tlv(160 + index, value);
|
|
3845
|
+
const booleanTrue = () => tlv(1, Buffer.from([255]));
|
|
3846
|
+
function integer$1(value) {
|
|
3847
|
+
return tlv(2, (value[0] ?? 0) & 128 ? Buffer.concat([Buffer.from([0]), value]) : value);
|
|
3848
|
+
}
|
|
3849
|
+
function oid(value) {
|
|
3850
|
+
const [first = 0, second = 0, ...rest] = value.split(".").map(Number);
|
|
3851
|
+
const bytes = [first * 40 + second];
|
|
3852
|
+
for (const part of rest) {
|
|
3853
|
+
const chunk = [part & 127];
|
|
3854
|
+
for (let remaining = part >> 7; remaining > 0; remaining >>= 7) chunk.unshift(remaining & 127 | 128);
|
|
3855
|
+
bytes.push(...chunk);
|
|
3856
|
+
}
|
|
3857
|
+
return tlv(6, Buffer.from(bytes));
|
|
3433
3858
|
}
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
3859
|
+
function time(date) {
|
|
3860
|
+
const iso = date.toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
3861
|
+
return date.getUTCFullYear() < 2050 ? tlv(23, Buffer.from(`${iso.slice(2)}Z`)) : tlv(24, Buffer.from(`${iso}Z`));
|
|
3438
3862
|
}
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
return url.replaceAll("&", "&").replaceAll("<", "%3C").replaceAll(">", "%3E");
|
|
3863
|
+
function name(commonName) {
|
|
3864
|
+
return sequence(set(sequence(oid(OID.commonName), tlv(12, Buffer.from(commonName)))));
|
|
3442
3865
|
}
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
return `<${escapeSlackLinkUrl(url)}|${escapeSlackMrkdwnText(label)}>`;
|
|
3866
|
+
function extension(id, critical, value) {
|
|
3867
|
+
return sequence(oid(id), ...critical ? [booleanTrue()] : [], octetString(value));
|
|
3446
3868
|
}
|
|
3447
|
-
function
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
let search = start + n;
|
|
3453
|
-
while (search < line.length) {
|
|
3454
|
-
const close = line.indexOf(marker, search);
|
|
3455
|
-
if (close === -1) return;
|
|
3456
|
-
const after = close + n;
|
|
3457
|
-
if (line[after] !== "`") return {
|
|
3458
|
-
text: line.slice(start, after),
|
|
3459
|
-
end: after
|
|
3460
|
-
};
|
|
3461
|
-
search = after + 1;
|
|
3462
|
-
}
|
|
3869
|
+
function publicKeyInfo(key) {
|
|
3870
|
+
return createPublicKey(key).export({
|
|
3871
|
+
type: "spki",
|
|
3872
|
+
format: "der"
|
|
3873
|
+
});
|
|
3463
3874
|
}
|
|
3464
|
-
function
|
|
3465
|
-
|
|
3466
|
-
const close = line.indexOf(">", start + 1);
|
|
3467
|
-
if (close === -1) return;
|
|
3468
|
-
const body = line.slice(start + 1, close);
|
|
3469
|
-
if (/^(?:https?:\/\/|@|#|!)/.test(body)) return {
|
|
3470
|
-
text: line.slice(start, close + 1),
|
|
3471
|
-
end: close + 1
|
|
3472
|
-
};
|
|
3875
|
+
function keyIdentifier(key) {
|
|
3876
|
+
return createHash("sha1").update(publicKeyInfo(key)).digest();
|
|
3473
3877
|
}
|
|
3474
|
-
function
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
const
|
|
3479
|
-
|
|
3480
|
-
const
|
|
3481
|
-
|
|
3878
|
+
function certificate(input) {
|
|
3879
|
+
const serial = randomBytes(16);
|
|
3880
|
+
serial[0] = (serial[0] ?? 0) & 127;
|
|
3881
|
+
const algorithm = sequence(oid(OID.ecdsaWithSha256));
|
|
3882
|
+
const now = Date.now();
|
|
3883
|
+
const tbs = sequence(explicit(0, integer$1(Buffer.from([2]))), integer$1(serial), algorithm, input.issuer, sequence(time(/* @__PURE__ */ new Date(now - DAY_MS)), time(new Date(now + input.validDays * DAY_MS))), input.subject, publicKeyInfo(input.publicKey), explicit(3, sequence(...input.extensions)));
|
|
3884
|
+
const signature = sign("sha256", tbs, {
|
|
3885
|
+
key: input.signingKey,
|
|
3886
|
+
dsaEncoding: "der"
|
|
3887
|
+
});
|
|
3888
|
+
return `-----BEGIN CERTIFICATE-----\n${sequence(tbs, algorithm, bitString(signature)).toString("base64").replace(/(.{64})/g, "$1\n").trim()}\n-----END CERTIFICATE-----\n`;
|
|
3889
|
+
}
|
|
3890
|
+
function newKey() {
|
|
3891
|
+
return generateKeyPairSync("ec", { namedCurve: "prime256v1" }).privateKey;
|
|
3892
|
+
}
|
|
3893
|
+
function createCa() {
|
|
3894
|
+
const key = newKey();
|
|
3895
|
+
const subject = name(CA_COMMON_NAME);
|
|
3482
3896
|
return {
|
|
3483
|
-
|
|
3484
|
-
|
|
3897
|
+
certPem: certificate({
|
|
3898
|
+
subject,
|
|
3899
|
+
issuer: subject,
|
|
3900
|
+
publicKey: key,
|
|
3901
|
+
signingKey: key,
|
|
3902
|
+
validDays: CA_VALID_DAYS,
|
|
3903
|
+
extensions: [
|
|
3904
|
+
extension(OID.basicConstraints, true, sequence(booleanTrue())),
|
|
3905
|
+
extension(OID.keyUsage, true, tlv(3, Buffer.from([1, 6]))),
|
|
3906
|
+
extension(OID.subjectKeyIdentifier, false, octetString(keyIdentifier(key)))
|
|
3907
|
+
]
|
|
3908
|
+
}),
|
|
3909
|
+
keyPem: key.export({
|
|
3910
|
+
type: "pkcs8",
|
|
3911
|
+
format: "pem"
|
|
3912
|
+
}).toString()
|
|
3485
3913
|
};
|
|
3486
3914
|
}
|
|
3487
|
-
function
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
}
|
|
3493
|
-
function readBareUrl(line, start) {
|
|
3494
|
-
let end = start;
|
|
3495
|
-
while (end < line.length) {
|
|
3496
|
-
const ch = line.charAt(end);
|
|
3497
|
-
if (/\s/.test(ch) || ch === "<" || ch === ">" || ch === "\"" || ch === "`" || ch === "|" || ch === "*") break;
|
|
3498
|
-
end++;
|
|
3915
|
+
async function readOrCreate(file, create) {
|
|
3916
|
+
try {
|
|
3917
|
+
return await fs.readFile(file, "utf8");
|
|
3918
|
+
} catch (error) {
|
|
3919
|
+
if (error.code !== "ENOENT") throw error;
|
|
3499
3920
|
}
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
if (!/^https?:\/\/.+/.test(raw)) return;
|
|
3510
|
-
return {
|
|
3511
|
-
url: raw,
|
|
3512
|
-
suffix,
|
|
3513
|
-
end
|
|
3514
|
-
};
|
|
3921
|
+
try {
|
|
3922
|
+
await fs.writeFile(file, create(), {
|
|
3923
|
+
flag: "wx",
|
|
3924
|
+
mode: 384
|
|
3925
|
+
});
|
|
3926
|
+
} catch (error) {
|
|
3927
|
+
if (error.code !== "EEXIST") throw error;
|
|
3928
|
+
}
|
|
3929
|
+
return await fs.readFile(file, "utf8");
|
|
3515
3930
|
}
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3931
|
+
/**
|
|
3932
|
+
* Load this machine's egress CA from `dir`, or create it.
|
|
3933
|
+
*
|
|
3934
|
+
* Every Junior process on the machine shares one CA, so a sandbox trusts the
|
|
3935
|
+
* proxy of any process that applies its network policy.
|
|
3936
|
+
*/
|
|
3937
|
+
async function loadEgressCa(dir) {
|
|
3938
|
+
await fs.mkdir(dir, {
|
|
3939
|
+
recursive: true,
|
|
3940
|
+
mode: 448
|
|
3941
|
+
});
|
|
3942
|
+
const keyFile = path.join(dir, "egress-ca-key.pem");
|
|
3943
|
+
const certFile = path.join(dir, "egress-ca.pem");
|
|
3944
|
+
let created;
|
|
3945
|
+
const make = () => created ??= createCa();
|
|
3946
|
+
const keyPem = await readOrCreate(keyFile, () => make().keyPem);
|
|
3947
|
+
const certPem = await readOrCreate(certFile, () => {
|
|
3948
|
+
if (!created) throw new Error(`${keyFile} exists without ${certFile}. Delete it.`);
|
|
3949
|
+
return created.certPem;
|
|
3950
|
+
});
|
|
3951
|
+
const key = createPrivateKey(keyPem);
|
|
3521
3952
|
return {
|
|
3522
|
-
|
|
3523
|
-
|
|
3953
|
+
certPem,
|
|
3954
|
+
key,
|
|
3955
|
+
keyId: keyIdentifier(key),
|
|
3956
|
+
subject: name(CA_COMMON_NAME)
|
|
3524
3957
|
};
|
|
3525
3958
|
}
|
|
3526
3959
|
/**
|
|
3527
|
-
*
|
|
3528
|
-
*
|
|
3960
|
+
* Issue a TLS certificate for one name, signed by the egress CA.
|
|
3961
|
+
* It works as a server certificate and as an egress relay client certificate.
|
|
3529
3962
|
*/
|
|
3530
|
-
function
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3963
|
+
function issueLeafCertificate(ca, host, key) {
|
|
3964
|
+
return certificate({
|
|
3965
|
+
subject: name(host),
|
|
3966
|
+
issuer: ca.subject,
|
|
3967
|
+
publicKey: key,
|
|
3968
|
+
signingKey: ca.key,
|
|
3969
|
+
validDays: LEAF_VALID_DAYS,
|
|
3970
|
+
extensions: [
|
|
3971
|
+
extension(OID.basicConstraints, true, sequence()),
|
|
3972
|
+
extension(OID.keyUsage, true, tlv(3, Buffer.from([7, 128]))),
|
|
3973
|
+
extension(OID.extKeyUsage, false, sequence(oid(OID.serverAuth), oid(OID.clientAuth))),
|
|
3974
|
+
extension(OID.subjectAltName, false, sequence(tlv(130, Buffer.from(host)))),
|
|
3975
|
+
extension(OID.authorityKeyIdentifier, false, sequence(tlv(128, ca.keyId)))
|
|
3976
|
+
]
|
|
3977
|
+
});
|
|
3978
|
+
}
|
|
3979
|
+
/** Create the private key that this process uses for every leaf certificate. */
|
|
3980
|
+
function createLeafKey() {
|
|
3981
|
+
return newKey();
|
|
3982
|
+
}
|
|
3983
|
+
// src/chat/sandbox/docker/runtime-image.ts
|
|
3984
|
+
const BASE_IMAGE = {
|
|
3985
|
+
name: "amazonlinux",
|
|
3986
|
+
tag: "2023"
|
|
3987
|
+
};
|
|
3988
|
+
const RUNTIME_REPOSITORY = "junior-sandbox-runtime";
|
|
3989
|
+
/** System packages for each runtime, installed on top of the base image. */
|
|
3990
|
+
const RUNTIME_PACKAGES = { node22: ["nodejs22", "nodejs22-npm"] };
|
|
3991
|
+
const COMMON_PACKAGES = [
|
|
3992
|
+
"coreutils",
|
|
3993
|
+
"diffutils",
|
|
3994
|
+
"findutils",
|
|
3995
|
+
"git",
|
|
3996
|
+
"gzip",
|
|
3997
|
+
"iptables-nft",
|
|
3998
|
+
"openssl",
|
|
3999
|
+
"procps-ng",
|
|
4000
|
+
"shadow-utils",
|
|
4001
|
+
"socat",
|
|
4002
|
+
"sudo",
|
|
4003
|
+
"tar",
|
|
4004
|
+
"unzip",
|
|
4005
|
+
"util-linux",
|
|
4006
|
+
"which"
|
|
4007
|
+
];
|
|
4008
|
+
function buildScript(runtime) {
|
|
4009
|
+
const packages = RUNTIME_PACKAGES[runtime];
|
|
4010
|
+
if (!packages) throw new Error(`The Docker sandbox provider has no runtime ${runtime}`);
|
|
4011
|
+
return [
|
|
4012
|
+
"set -eu",
|
|
4013
|
+
`dnf install -y --allowerasing ${[...packages, ...COMMON_PACKAGES].join(" ")}`,
|
|
4014
|
+
"dnf clean all",
|
|
4015
|
+
`useradd --create-home --uid 1000 ${SANDBOX_USER}`,
|
|
4016
|
+
`echo '${SANDBOX_USER} ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/${SANDBOX_USER}`,
|
|
4017
|
+
`chmod 0440 /etc/sudoers.d/${SANDBOX_USER}`,
|
|
4018
|
+
`mkdir -p ${SANDBOX_WORKSPACE_ROOT}`,
|
|
4019
|
+
`chown ${SANDBOX_USER}:${SANDBOX_USER} ${SANDBOX_WORKSPACE_ROOT}`,
|
|
4020
|
+
`mkdir -p -m 1777 ${COMMAND_STATE_DIR}`
|
|
4021
|
+
].join("\n");
|
|
4022
|
+
}
|
|
4023
|
+
function runtimeTag(runtime) {
|
|
4024
|
+
return `${runtime}-${createHash("sha256").update(`${BASE_IMAGE.name}:${BASE_IMAGE.tag}\n${buildScript(runtime)}`).digest("hex").slice(0, 12)}`;
|
|
4025
|
+
}
|
|
4026
|
+
async function imageExists(reference) {
|
|
4027
|
+
try {
|
|
4028
|
+
await dockerRequest({ path: `/images/${encodeURIComponent(reference)}/json` });
|
|
4029
|
+
return true;
|
|
4030
|
+
} catch (error) {
|
|
4031
|
+
if (error instanceof DockerApiError && error.statusCode === 404) return false;
|
|
4032
|
+
throw error;
|
|
3568
4033
|
}
|
|
3569
|
-
return result;
|
|
3570
4034
|
}
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
const lines = text.split("\n");
|
|
3582
|
-
const out = [];
|
|
3583
|
-
let inCodeBlock = false;
|
|
4035
|
+
async function pullBaseImage(signal) {
|
|
4036
|
+
const lines = (await readStream(await dockerOkStream({
|
|
4037
|
+
method: "POST",
|
|
4038
|
+
path: "/images/create",
|
|
4039
|
+
query: {
|
|
4040
|
+
fromImage: BASE_IMAGE.name,
|
|
4041
|
+
tag: BASE_IMAGE.tag
|
|
4042
|
+
},
|
|
4043
|
+
signal
|
|
4044
|
+
}))).toString("utf8").split("\n");
|
|
3584
4045
|
for (const line of lines) {
|
|
3585
|
-
if (line.
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
continue;
|
|
3589
|
-
}
|
|
3590
|
-
out.push(inCodeBlock ? line : wrapBareReferencesOnLine(line));
|
|
4046
|
+
if (!line.trim()) continue;
|
|
4047
|
+
const event = JSON.parse(line);
|
|
4048
|
+
if (event.error) throw new Error(`Docker pull failed: ${event.error}`);
|
|
3591
4049
|
}
|
|
3592
|
-
return out.join("\n");
|
|
3593
4050
|
}
|
|
3594
|
-
const LIST_ITEM_PATTERN = /^[-*•]\s|^\d+\.\s/;
|
|
3595
4051
|
/**
|
|
3596
|
-
*
|
|
3597
|
-
* indent.
|
|
4052
|
+
* Return the local image for a sandbox runtime. Build it on first use.
|
|
3598
4053
|
*
|
|
3599
|
-
*
|
|
3600
|
-
*
|
|
3601
|
-
*
|
|
3602
|
-
* nesting.
|
|
4054
|
+
* The tag holds a hash of the build script, so a changed script builds a new
|
|
4055
|
+
* image. Two processes may build the same image at once. The last commit wins,
|
|
4056
|
+
* and both images work.
|
|
3603
4057
|
*/
|
|
3604
|
-
function
|
|
3605
|
-
const
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
4058
|
+
async function ensureRuntimeImage(runtime, signal) {
|
|
4059
|
+
const reference = `${RUNTIME_REPOSITORY}:${runtimeTag(runtime)}`;
|
|
4060
|
+
if (await imageExists(reference)) return reference;
|
|
4061
|
+
const base = `${BASE_IMAGE.name}:${BASE_IMAGE.tag}`;
|
|
4062
|
+
if (!await imageExists(base)) await pullBaseImage(signal);
|
|
4063
|
+
const name = `junior-runtime-build-${randomUUID()}`;
|
|
4064
|
+
const created = await dockerJson({
|
|
4065
|
+
method: "POST",
|
|
4066
|
+
path: "/containers/create",
|
|
4067
|
+
query: { name },
|
|
4068
|
+
body: {
|
|
4069
|
+
Image: base,
|
|
4070
|
+
Cmd: ["sleep", "infinity"],
|
|
4071
|
+
Labels: { [DOCKER_SANDBOX_LABEL]: name }
|
|
4072
|
+
},
|
|
4073
|
+
signal
|
|
4074
|
+
});
|
|
4075
|
+
try {
|
|
4076
|
+
await dockerRequest({
|
|
4077
|
+
method: "POST",
|
|
4078
|
+
path: `/containers/${created.Id}/start`,
|
|
4079
|
+
signal
|
|
4080
|
+
});
|
|
4081
|
+
const result = await runRawExec(created.Id, {
|
|
4082
|
+
cmd: [
|
|
4083
|
+
"sh",
|
|
4084
|
+
"-c",
|
|
4085
|
+
buildScript(runtime)
|
|
4086
|
+
],
|
|
4087
|
+
root: true
|
|
4088
|
+
}, signal);
|
|
4089
|
+
if (result.exitCode !== 0) throw new Error(`Docker sandbox runtime ${runtime} build failed (exit ${result.exitCode}): ${result.stderr.slice(-2e3)}`);
|
|
4090
|
+
const [repo, tag] = reference.split(":");
|
|
4091
|
+
await dockerRequest({
|
|
4092
|
+
method: "POST",
|
|
4093
|
+
path: "/commit",
|
|
4094
|
+
query: {
|
|
4095
|
+
container: created.Id,
|
|
4096
|
+
repo,
|
|
4097
|
+
tag,
|
|
4098
|
+
changes: `LABEL ${DOCKER_SANDBOX_LABEL}=runtime`
|
|
4099
|
+
},
|
|
4100
|
+
signal
|
|
4101
|
+
});
|
|
4102
|
+
return reference;
|
|
4103
|
+
} finally {
|
|
4104
|
+
await dockerRequest({
|
|
4105
|
+
method: "DELETE",
|
|
4106
|
+
path: `/containers/${created.Id}`,
|
|
4107
|
+
query: {
|
|
4108
|
+
force: true,
|
|
4109
|
+
v: true
|
|
3628
4110
|
}
|
|
3629
|
-
|
|
3630
|
-
result.push(line);
|
|
3631
|
-
continue;
|
|
3632
|
-
}
|
|
3633
|
-
if (inCodeBlock) {
|
|
3634
|
-
result.push(line);
|
|
3635
|
-
continue;
|
|
3636
|
-
}
|
|
3637
|
-
const prev = result.length > 0 ? result[result.length - 1] : void 0;
|
|
3638
|
-
const isListItem = LIST_ITEM_PATTERN.test(line.trimStart());
|
|
3639
|
-
const isListContinuation = prev !== void 0 && !isListItem && /^\s/.test(line) && (/^\s/.test(prev) || LIST_ITEM_PATTERN.test(prev));
|
|
3640
|
-
if (prev !== void 0 && prev.trim() !== "" && line.trim() !== "" && !isListContinuation && !(LIST_ITEM_PATTERN.test(prev.trimStart()) && isListItem)) result.push("");
|
|
3641
|
-
result.push(line);
|
|
4111
|
+
}).catch(() => {});
|
|
3642
4112
|
}
|
|
3643
|
-
return result.join("\n");
|
|
3644
4113
|
}
|
|
4114
|
+
// src/chat/sandbox/snapshot/runtime.ts
|
|
4115
|
+
/** Runtime used to build and start reusable Sandbox snapshots. */
|
|
4116
|
+
const SANDBOX_RUNTIME = "node22";
|
|
4117
|
+
// src/chat/sandbox/docker/relay.ts
|
|
4118
|
+
/** Host name of the Docker host as seen from a container. */
|
|
4119
|
+
const RELAY_PROXY_HOST = "host.docker.internal";
|
|
4120
|
+
const RELAY_PORT = 15080;
|
|
4121
|
+
const RELAY_DIR = "/run/junior-relay";
|
|
4122
|
+
/** Label with the proxy port that the relay sends to. */
|
|
4123
|
+
const TARGET_LABEL = "dev.junior.sandbox.relay-target";
|
|
3645
4124
|
/**
|
|
3646
|
-
*
|
|
3647
|
-
* or `{ type: "markdown" }` blocks.
|
|
4125
|
+
* Return whether sandboxes reach the proxy through a relay.
|
|
3648
4126
|
*
|
|
3649
|
-
*
|
|
3650
|
-
*
|
|
3651
|
-
* paragraph bullets. Slack reply delivery owns chunking and continuation
|
|
3652
|
-
* markers separately.
|
|
4127
|
+
* On Linux, the Docker daemon runs on this host, and the sandbox network
|
|
4128
|
+
* gateway is a host address. Other systems run the daemon in a VM.
|
|
3653
4129
|
*/
|
|
3654
|
-
function
|
|
3655
|
-
|
|
3656
|
-
normalized = wrapBareReferences(normalized);
|
|
3657
|
-
normalized = ensureBlockSpacing(normalized);
|
|
3658
|
-
normalized = bulletListFollowUpParagraphs(normalized);
|
|
3659
|
-
return normalized.replace(/\n{3,}/g, "\n\n").trim();
|
|
4130
|
+
function usesEgressRelay() {
|
|
4131
|
+
return process.platform !== "linux";
|
|
3660
4132
|
}
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
4133
|
+
async function inspectRelay(name) {
|
|
4134
|
+
try {
|
|
4135
|
+
return await dockerJson({ path: `/containers/${encodeURIComponent(name)}/json` });
|
|
4136
|
+
} catch (error) {
|
|
4137
|
+
if (error instanceof DockerApiError && error.statusCode === 404) return;
|
|
4138
|
+
throw error;
|
|
4139
|
+
}
|
|
3666
4140
|
}
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
4141
|
+
function relayAddress(container, relay) {
|
|
4142
|
+
const ip = relay.NetworkSettings.Networks[container.name]?.IPAddress;
|
|
4143
|
+
if (!ip) throw new Error(`Egress relay of ${container.name} has no address`);
|
|
4144
|
+
return `${ip}:${RELAY_PORT}`;
|
|
4145
|
+
}
|
|
4146
|
+
function relayFiles(ca, container) {
|
|
4147
|
+
const key = createLeafKey();
|
|
4148
|
+
const files = {
|
|
4149
|
+
"client.pem": issueLeafCertificate(ca, container.id, key),
|
|
4150
|
+
"client-key.pem": key.export({
|
|
4151
|
+
type: "pkcs8",
|
|
4152
|
+
format: "pem"
|
|
4153
|
+
}).toString(),
|
|
4154
|
+
"ca.pem": ca.certPem
|
|
4155
|
+
};
|
|
4156
|
+
return packTar(Object.entries(files).map(([file, content]) => ({
|
|
4157
|
+
path: `${RELAY_DIR.slice(1)}/${file}`,
|
|
4158
|
+
content: Buffer.from(content),
|
|
4159
|
+
mode: 384,
|
|
4160
|
+
uid: 0,
|
|
4161
|
+
gid: 0
|
|
4162
|
+
})));
|
|
3670
4163
|
}
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
4164
|
+
/**
|
|
4165
|
+
* Start the egress relay of one sandbox, or reuse it, and return the address
|
|
4166
|
+
* that the sandbox's redirect rules send traffic to.
|
|
4167
|
+
*
|
|
4168
|
+
* A relay sends to one proxy port. When another process applies the policy,
|
|
4169
|
+
* this replaces the relay so that it sends to that process's proxy.
|
|
4170
|
+
*/
|
|
4171
|
+
async function ensureEgressRelay(container, ca, proxyPort) {
|
|
4172
|
+
const name = egressRelayName(container.name);
|
|
4173
|
+
const existing = await inspectRelay(name);
|
|
4174
|
+
if (existing?.State.Running && existing.Config.Labels[TARGET_LABEL] === String(proxyPort)) return relayAddress(container, existing);
|
|
4175
|
+
if (existing) await dockerRequest({
|
|
4176
|
+
method: "DELETE",
|
|
4177
|
+
path: `/containers/${existing.Id}`,
|
|
4178
|
+
query: { force: true }
|
|
4179
|
+
});
|
|
4180
|
+
const created = await dockerJson({
|
|
4181
|
+
method: "POST",
|
|
4182
|
+
path: "/containers/create",
|
|
4183
|
+
query: { name },
|
|
4184
|
+
body: {
|
|
4185
|
+
Image: await ensureRuntimeImage(SANDBOX_RUNTIME),
|
|
4186
|
+
Cmd: [
|
|
4187
|
+
"socat",
|
|
4188
|
+
`TCP-LISTEN:${RELAY_PORT},fork,reuseaddr`,
|
|
4189
|
+
`OPENSSL:${RELAY_PROXY_HOST}:${proxyPort},cert=${RELAY_DIR}/client.pem,key=${RELAY_DIR}/client-key.pem,cafile=${RELAY_DIR}/ca.pem`
|
|
4190
|
+
],
|
|
4191
|
+
User: "root",
|
|
4192
|
+
Labels: {
|
|
4193
|
+
[DOCKER_SANDBOX_LABEL]: `relay:${container.name}`,
|
|
4194
|
+
[TARGET_LABEL]: String(proxyPort)
|
|
3682
4195
|
},
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
type: "plain_text",
|
|
3687
|
-
text: "Connect"
|
|
3688
|
-
},
|
|
3689
|
-
style: "primary",
|
|
3690
|
-
url: args.authorizationUrl,
|
|
3691
|
-
action_id: "oauth_connect"
|
|
4196
|
+
HostConfig: {
|
|
4197
|
+
NetworkMode: container.name,
|
|
4198
|
+
ExtraHosts: [`${RELAY_PROXY_HOST}:host-gateway`]
|
|
3692
4199
|
}
|
|
3693
|
-
}
|
|
3694
|
-
};
|
|
4200
|
+
}
|
|
4201
|
+
});
|
|
4202
|
+
await dockerRequest({
|
|
4203
|
+
method: "PUT",
|
|
4204
|
+
path: `/containers/${created.Id}/archive`,
|
|
4205
|
+
query: { path: "/" },
|
|
4206
|
+
body: relayFiles(ca, container)
|
|
4207
|
+
});
|
|
4208
|
+
await dockerRequest({
|
|
4209
|
+
method: "POST",
|
|
4210
|
+
path: `/containers/${created.Id}/start`
|
|
4211
|
+
});
|
|
4212
|
+
const started = await inspectRelay(created.Id);
|
|
4213
|
+
if (!started?.State.Running) throw new Error(`Egress relay of ${container.name} did not start`);
|
|
4214
|
+
return relayAddress(container, started);
|
|
3695
4215
|
}
|
|
3696
|
-
// src/chat/
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
return
|
|
3700
|
-
}
|
|
3701
|
-
/** Parse OAuth callback state that was persisted before a provider redirect. */
|
|
3702
|
-
function parseOAuthStatePayload(value) {
|
|
3703
|
-
if (!isRecord$2(value)) return;
|
|
3704
|
-
if (typeof value.userId !== "string" || typeof value.provider !== "string") return;
|
|
3705
|
-
const actor = value.actor === void 0 ? void 0 : actorSchema.safeParse(value.actor);
|
|
3706
|
-
if (value.actor !== void 0 && (!actor || !actor.success)) return;
|
|
3707
|
-
const destination = parseDestination(value.destination);
|
|
3708
|
-
if (value.destination !== void 0 && !destination) return;
|
|
3709
|
-
const source = value.source === void 0 ? void 0 : sourceSchema.safeParse(value.source);
|
|
3710
|
-
if (value.source !== void 0 && (!source || !source.success)) return;
|
|
3711
|
-
return {
|
|
3712
|
-
userId: value.userId,
|
|
3713
|
-
provider: value.provider,
|
|
3714
|
-
...actor?.success ? { actor: actor.data } : void 0,
|
|
3715
|
-
...optionalString(value.channelId) ? { channelId: optionalString(value.channelId) } : void 0,
|
|
3716
|
-
...destination ? { destination } : void 0,
|
|
3717
|
-
...source?.success ? { source: source.data } : void 0,
|
|
3718
|
-
...optionalString(value.threadTs) ? { threadTs: optionalString(value.threadTs) } : void 0,
|
|
3719
|
-
...optionalString(value.resumeConversationId) ? { resumeConversationId: optionalString(value.resumeConversationId) } : void 0,
|
|
3720
|
-
...optionalString(value.resumeSessionId) ? { resumeSessionId: optionalString(value.resumeSessionId) } : void 0,
|
|
3721
|
-
...optionalString(value.scope) ? { scope: optionalString(value.scope) } : void 0
|
|
3722
|
-
};
|
|
3723
|
-
}
|
|
3724
|
-
/** Return the manifest-owned display label for a provider. */
|
|
3725
|
-
function formatProviderLabel(provider) {
|
|
3726
|
-
const displayName = pluginCatalogRuntime.getDisplayName(provider);
|
|
3727
|
-
if (!displayName) throw new Error(`Unknown plugin provider display name: "${provider}"`);
|
|
3728
|
-
return displayName;
|
|
3729
|
-
}
|
|
3730
|
-
/** Resolve the public base URL from environment variables (JUNIOR_BASE_URL or Vercel). */
|
|
3731
|
-
function resolveBaseUrl() {
|
|
3732
|
-
const explicit = process.env.JUNIOR_BASE_URL?.trim();
|
|
3733
|
-
if (explicit) return explicit;
|
|
3734
|
-
const vercelProd = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
|
|
3735
|
-
if (vercelProd) return `https://${vercelProd}`;
|
|
3736
|
-
const vercelUrl = process.env.VERCEL_URL?.trim();
|
|
3737
|
-
if (vercelUrl) return `https://${vercelUrl}`;
|
|
3738
|
-
}
|
|
3739
|
-
/**
|
|
3740
|
-
* Authorization links must only be visible to the requesting user.
|
|
3741
|
-
* Try in-context private delivery first, then fall back to a DM.
|
|
3742
|
-
*/
|
|
3743
|
-
async function deliverPrivateMessage(input) {
|
|
3744
|
-
let client;
|
|
3745
|
-
try {
|
|
3746
|
-
client = getSlackClient();
|
|
3747
|
-
} catch {
|
|
3748
|
-
logWarn("oauth.private_delivery.skipped", { "app.reason": "missing_bot_token" });
|
|
3749
|
-
return false;
|
|
3750
|
-
}
|
|
3751
|
-
if (input.channelId) try {
|
|
3752
|
-
if (isDmChannel(input.channelId)) await postSlackMessage({
|
|
3753
|
-
channelId: input.channelId,
|
|
3754
|
-
text: input.text,
|
|
3755
|
-
blocks: input.blocks,
|
|
3756
|
-
threadTs: input.threadTs
|
|
3757
|
-
});
|
|
3758
|
-
else await postSlackEphemeralMessage({
|
|
3759
|
-
channelId: input.channelId,
|
|
3760
|
-
userId: input.userId,
|
|
3761
|
-
text: input.text,
|
|
3762
|
-
blocks: input.blocks,
|
|
3763
|
-
threadTs: input.threadTs
|
|
3764
|
-
});
|
|
3765
|
-
return "in_context";
|
|
3766
|
-
} catch (error) {
|
|
3767
|
-
logWarn("oauth.private_delivery.failed", {
|
|
3768
|
-
"app.slack.error": error instanceof Error ? error.message : String(error),
|
|
3769
|
-
"app.slack.channel": input.channelId
|
|
3770
|
-
});
|
|
3771
|
-
}
|
|
3772
|
-
try {
|
|
3773
|
-
const dmChannelId = (await withSlackRetries(() => client.conversations.open({ users: input.userId }), 3, {
|
|
3774
|
-
action: "conversations.open",
|
|
3775
|
-
spanAttributes: { "app.slack.user_id": input.userId }
|
|
3776
|
-
})).channel?.id;
|
|
3777
|
-
if (!dmChannelId) {
|
|
3778
|
-
logWarn("oauth.dm.fallback.failed", { "app.reason": "no_dm_channel_id" });
|
|
3779
|
-
return false;
|
|
3780
|
-
}
|
|
3781
|
-
await postSlackMessage({
|
|
3782
|
-
channelId: dmChannelId,
|
|
3783
|
-
text: input.text,
|
|
3784
|
-
blocks: input.blocks
|
|
3785
|
-
});
|
|
3786
|
-
return "fallback_dm";
|
|
3787
|
-
} catch (error) {
|
|
3788
|
-
logWarn("oauth.dm.fallback.failed", { "app.slack.error": error instanceof Error ? error.message : String(error) });
|
|
3789
|
-
return false;
|
|
3790
|
-
}
|
|
3791
|
-
}
|
|
3792
|
-
/** Present one OAuth request through the active surface's private delivery. */
|
|
3793
|
-
async function deliverOAuthAuthorization(request, input) {
|
|
3794
|
-
if (input.authorization) {
|
|
3795
|
-
await input.authorization.deliver(request);
|
|
3796
|
-
return "in_context";
|
|
3797
|
-
}
|
|
3798
|
-
return await deliverPrivateMessage({
|
|
3799
|
-
channelId: input.channelId,
|
|
3800
|
-
threadTs: input.threadTs,
|
|
3801
|
-
userId: input.userId,
|
|
3802
|
-
...buildSlackOAuthAuthorizationMessage(request)
|
|
3803
|
-
});
|
|
3804
|
-
}
|
|
3805
|
-
/** Initiate an OAuth authorization code flow for a provider and deliver the auth link to the user. */
|
|
3806
|
-
async function startOAuthFlow(provider, input) {
|
|
3807
|
-
const providerConfig = pluginCatalogRuntime.getOAuthConfig(provider);
|
|
3808
|
-
if (!providerConfig) return {
|
|
3809
|
-
ok: false,
|
|
3810
|
-
error: `Provider "${provider}" does not support OAuth authorization`
|
|
3811
|
-
};
|
|
3812
|
-
const clientId = process.env[providerConfig.clientIdEnv]?.trim();
|
|
3813
|
-
if (!clientId) return {
|
|
3814
|
-
ok: false,
|
|
3815
|
-
error: `Missing ${providerConfig.clientIdEnv} environment variable`
|
|
3816
|
-
};
|
|
3817
|
-
const baseUrl = resolveBaseUrl();
|
|
3818
|
-
if (!baseUrl) return {
|
|
3819
|
-
ok: false,
|
|
3820
|
-
error: "Cannot determine base URL (set JUNIOR_BASE_URL or deploy to Vercel)"
|
|
3821
|
-
};
|
|
3822
|
-
const state = input.authorization ? await input.authorization.createState() : randomBytes(32).toString("hex");
|
|
3823
|
-
const requestedScope = input.scope ?? providerConfig.scope;
|
|
3824
|
-
await getStateAdapter().set(`oauth-state:${state}`, {
|
|
3825
|
-
userId: input.actorId,
|
|
3826
|
-
provider,
|
|
3827
|
-
...input.actor ? { actor: input.actor } : void 0,
|
|
3828
|
-
...input.channelId ? { channelId: input.channelId } : void 0,
|
|
3829
|
-
...input.destination ? { destination: input.destination } : void 0,
|
|
3830
|
-
...input.source ? { source: input.source } : void 0,
|
|
3831
|
-
...input.threadTs ? { threadTs: input.threadTs } : void 0,
|
|
3832
|
-
...input.resumeConversationId ? { resumeConversationId: input.resumeConversationId } : void 0,
|
|
3833
|
-
...input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : void 0,
|
|
3834
|
-
...requestedScope ? { scope: requestedScope } : void 0
|
|
3835
|
-
}, OAUTH_STATE_TTL_MS);
|
|
3836
|
-
const authorizeParams = new URLSearchParams({
|
|
3837
|
-
client_id: clientId,
|
|
3838
|
-
state,
|
|
3839
|
-
redirect_uri: `${baseUrl}${providerConfig.callbackPath}`,
|
|
3840
|
-
response_type: "code"
|
|
3841
|
-
});
|
|
3842
|
-
if (requestedScope) authorizeParams.set("scope", requestedScope);
|
|
3843
|
-
for (const [key, value] of Object.entries(providerConfig.authorizeParams ?? {})) authorizeParams.set(key, value);
|
|
3844
|
-
logInfo("jr_rpc.oauth.started", {
|
|
3845
|
-
"app.credential.provider": provider,
|
|
3846
|
-
...input.activeSkillName ? { "app.skill.name": input.activeSkillName } : void 0
|
|
3847
|
-
});
|
|
3848
|
-
return {
|
|
3849
|
-
ok: true,
|
|
3850
|
-
delivery: await deliverOAuthAuthorization({
|
|
3851
|
-
authorizationUrl: `${providerConfig.authorizeEndpoint}?${authorizeParams.toString()}`,
|
|
3852
|
-
label: `Connect your ${formatProviderLabel(provider)} account`,
|
|
3853
|
-
completionText: input.resumeSessionId ? "Once you've authorized, I'll continue automatically." : "Once you've authorized, you'll see a confirmation in Slack."
|
|
3854
|
-
}, {
|
|
3855
|
-
authorization: input.authorization,
|
|
3856
|
-
channelId: input.channelId,
|
|
3857
|
-
threadTs: input.threadTs,
|
|
3858
|
-
userId: input.actorId
|
|
3859
|
-
})
|
|
3860
|
-
};
|
|
3861
|
-
}
|
|
3862
|
-
// src/chat/sandbox/egress/oidc.ts
|
|
3863
|
-
const OIDC_DISCOVERY_CACHE_TTL_MS = 36e5;
|
|
3864
|
-
const OIDC_DISCOVERY_CACHE_MAX_ENTRIES = 8;
|
|
3865
|
-
const jwksByIssuer = /* @__PURE__ */ new Map();
|
|
3866
|
-
function buildDiscoveryUrl(issuer) {
|
|
3867
|
-
const url = new URL(issuer);
|
|
3868
|
-
if (url.protocol !== "https:" || url.hostname !== "oidc.vercel.com") throw new Error("Unexpected Vercel OIDC issuer");
|
|
3869
|
-
url.pathname = `${url.pathname.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
|
3870
|
-
url.search = "";
|
|
3871
|
-
url.hash = "";
|
|
3872
|
-
return url;
|
|
3873
|
-
}
|
|
3874
|
-
function buildJwksUrl(value) {
|
|
3875
|
-
const url = new URL(value);
|
|
3876
|
-
if (url.protocol !== "https:") throw new Error("Vercel OIDC discovery jwks_uri must use HTTPS");
|
|
3877
|
-
return url;
|
|
3878
|
-
}
|
|
3879
|
-
async function getJwks(issuer) {
|
|
3880
|
-
const now = Date.now();
|
|
3881
|
-
const cached = jwksByIssuer.get(issuer);
|
|
3882
|
-
if (cached && cached.expiresAtMs > now) return cached.jwks;
|
|
3883
|
-
if (cached) jwksByIssuer.delete(issuer);
|
|
3884
|
-
const response = await fetch(buildDiscoveryUrl(issuer), { redirect: "error" });
|
|
3885
|
-
if (!response.ok) throw new Error("Unable to load Vercel OIDC discovery metadata");
|
|
3886
|
-
const config = await response.json();
|
|
3887
|
-
if (!config.jwks_uri) throw new Error("Vercel OIDC discovery metadata did not include jwks_uri");
|
|
3888
|
-
const jwks = createRemoteJWKSet(buildJwksUrl(config.jwks_uri));
|
|
3889
|
-
if (!jwksByIssuer.has(issuer) && jwksByIssuer.size >= OIDC_DISCOVERY_CACHE_MAX_ENTRIES) {
|
|
3890
|
-
const oldestIssuer = jwksByIssuer.keys().next().value;
|
|
3891
|
-
if (oldestIssuer) jwksByIssuer.delete(oldestIssuer);
|
|
3892
|
-
}
|
|
3893
|
-
jwksByIssuer.set(issuer, {
|
|
3894
|
-
jwks,
|
|
3895
|
-
expiresAtMs: now + OIDC_DISCOVERY_CACHE_TTL_MS
|
|
3896
|
-
});
|
|
3897
|
-
return jwks;
|
|
3898
|
-
}
|
|
3899
|
-
/**
|
|
3900
|
-
* Verify Vercel signed this Sandbox firewall proxy request for the active VM session.
|
|
3901
|
-
*
|
|
3902
|
-
* `currentDate` is the time for the `exp` and `nbf` checks. It defaults to
|
|
3903
|
-
* now. Evals pass the real time because they replace `Date`.
|
|
3904
|
-
*/
|
|
3905
|
-
async function verifyVercelSandboxOidcToken(token, options = {}) {
|
|
3906
|
-
const unverified = decodeJwt(token);
|
|
3907
|
-
if (typeof unverified.iss !== "string") throw new Error("Vercel OIDC token did not include an issuer");
|
|
3908
|
-
const jwks = await getJwks(unverified.iss);
|
|
3909
|
-
return (await jwtVerify(token, jwks, {
|
|
3910
|
-
issuer: unverified.iss,
|
|
3911
|
-
...options.currentDate ? { currentDate: options.currentDate } : void 0
|
|
3912
|
-
})).payload;
|
|
3913
|
-
}
|
|
3914
|
-
// src/chat/sandbox/egress/tracing.ts
|
|
3915
|
-
function isValidDomainPattern(domain) {
|
|
3916
|
-
if (domain.includes("*")) return domain.startsWith("*.") && domain.indexOf("*", 1) === -1;
|
|
3917
|
-
return true;
|
|
4216
|
+
// src/chat/sandbox/egress/tracing.ts
|
|
4217
|
+
function isValidDomainPattern(domain) {
|
|
4218
|
+
if (domain.includes("*")) return domain.startsWith("*.") && domain.indexOf("*", 1) === -1;
|
|
4219
|
+
return true;
|
|
3918
4220
|
}
|
|
3919
4221
|
/** Normalize exact and leading-wildcard sandbox egress trace domains. */
|
|
3920
4222
|
function normalizeSandboxEgressTracePropagationDomains(domains) {
|
|
@@ -4325,60 +4627,1811 @@ function continueSandboxEgressTrace(request, upstreamHost, tracePropagation, cre
|
|
|
4325
4627
|
"http.request.method": request.method,
|
|
4326
4628
|
"url.path": redactSandboxEgressProxyPath(new URL(request.url).pathname)
|
|
4327
4629
|
});
|
|
4328
|
-
if (!shouldPropagateSandboxEgressTrace(upstreamHost, tracePropagation) || !sentryTrace && !baggage) return run();
|
|
4329
|
-
return continueTrace({
|
|
4330
|
-
sentryTrace,
|
|
4331
|
-
baggage
|
|
4332
|
-
}, run);
|
|
4630
|
+
if (!shouldPropagateSandboxEgressTrace(upstreamHost, tracePropagation) || !sentryTrace && !baggage) return run();
|
|
4631
|
+
return continueTrace({
|
|
4632
|
+
sentryTrace,
|
|
4633
|
+
baggage
|
|
4634
|
+
}, run);
|
|
4635
|
+
}
|
|
4636
|
+
/**
|
|
4637
|
+
* Run one sandbox egress request that its sandbox provider authenticated.
|
|
4638
|
+
*
|
|
4639
|
+
* This is the shared authorization boundary. It rejects upstream hosts that no
|
|
4640
|
+
* plugin owns, rejects signed credential contexts that do not match the
|
|
4641
|
+
* verified VM id, and only then asks the credentialed egress engine to issue
|
|
4642
|
+
* credentials and contact the upstream provider.
|
|
4643
|
+
*/
|
|
4644
|
+
async function executeSandboxEgressRequest(input, deps = {}) {
|
|
4645
|
+
const { request, upstreamUrl } = input;
|
|
4646
|
+
const activeEgressId = input.egressId;
|
|
4647
|
+
const provider = resolveSandboxEgressProviderForHost(upstreamUrl.hostname);
|
|
4648
|
+
if (!provider) {
|
|
4649
|
+
logWarn("sandbox.egress.provider.unresolved", {
|
|
4650
|
+
...sandboxEgressAttributes({
|
|
4651
|
+
egressId: activeEgressId,
|
|
4652
|
+
host: upstreamUrl.hostname,
|
|
4653
|
+
method: request.method,
|
|
4654
|
+
path: upstreamUrl.pathname,
|
|
4655
|
+
status: 403
|
|
4656
|
+
}),
|
|
4657
|
+
...sandboxEgressRoutingAttributes(request, upstreamUrl)
|
|
4658
|
+
});
|
|
4659
|
+
return jsonError$1("No provider owns forwarded host", 403);
|
|
4660
|
+
}
|
|
4661
|
+
const credentialContext = parseSandboxEgressCredentialToken(input.credentialToken);
|
|
4662
|
+
if (!credentialContext || credentialContext.egressId !== activeEgressId) {
|
|
4663
|
+
logWarn("sandbox.egress.credential_context.unauthorized", {
|
|
4664
|
+
...sandboxEgressAttributes({
|
|
4665
|
+
egressId: activeEgressId,
|
|
4666
|
+
host: upstreamUrl.hostname,
|
|
4667
|
+
method: request.method,
|
|
4668
|
+
path: upstreamUrl.pathname,
|
|
4669
|
+
provider,
|
|
4670
|
+
status: 403
|
|
4671
|
+
}),
|
|
4672
|
+
...sandboxEgressRoutingAttributes(request, upstreamUrl)
|
|
4673
|
+
});
|
|
4674
|
+
return jsonError$1("Sandbox egress credential context is not authorized", 403);
|
|
4675
|
+
}
|
|
4676
|
+
return await continueSandboxEgressTrace(request, upstreamUrl.hostname, deps.tracePropagation ?? {}, credentialContext, async () => await executeCredentialedEgressRequest({
|
|
4677
|
+
activeEgressId,
|
|
4678
|
+
credentialContext,
|
|
4679
|
+
deps,
|
|
4680
|
+
provider,
|
|
4681
|
+
request,
|
|
4682
|
+
upstreamUrl
|
|
4683
|
+
}));
|
|
4684
|
+
}
|
|
4685
|
+
// src/chat/sandbox/docker/egress.ts
|
|
4686
|
+
const CHAIN = "JUNIOR_EGRESS";
|
|
4687
|
+
/** Private destinations never go to the proxy. Provider domains are public. */
|
|
4688
|
+
const DIRECT_RANGES = [
|
|
4689
|
+
"127.0.0.0/8",
|
|
4690
|
+
"10.0.0.0/8",
|
|
4691
|
+
"172.16.0.0/12",
|
|
4692
|
+
"192.168.0.0/16"
|
|
4693
|
+
];
|
|
4694
|
+
/**
|
|
4695
|
+
* Pass-through connections start in the Junior process, so a sandbox could
|
|
4696
|
+
* name a host-local or internal service in SNI or `Host`. Block every
|
|
4697
|
+
* address that is not public.
|
|
4698
|
+
*/
|
|
4699
|
+
const BLOCKED_PASS_THROUGH = new net.BlockList();
|
|
4700
|
+
for (const [address, prefix] of [
|
|
4701
|
+
["0.0.0.0", 8],
|
|
4702
|
+
["10.0.0.0", 8],
|
|
4703
|
+
["100.64.0.0", 10],
|
|
4704
|
+
["127.0.0.0", 8],
|
|
4705
|
+
["169.254.0.0", 16],
|
|
4706
|
+
["172.16.0.0", 12],
|
|
4707
|
+
["192.0.0.0", 24],
|
|
4708
|
+
["192.168.0.0", 16],
|
|
4709
|
+
["198.18.0.0", 15],
|
|
4710
|
+
["224.0.0.0", 3]
|
|
4711
|
+
]) BLOCKED_PASS_THROUGH.addSubnet(address, prefix, "ipv4");
|
|
4712
|
+
for (const [address, prefix] of [
|
|
4713
|
+
["::", 128],
|
|
4714
|
+
["::1", 128],
|
|
4715
|
+
["64:ff9b::", 96],
|
|
4716
|
+
["fc00::", 7],
|
|
4717
|
+
["fe80::", 10],
|
|
4718
|
+
["ff00::", 8]
|
|
4719
|
+
]) BLOCKED_PASS_THROUGH.addSubnet(address, prefix, "ipv6");
|
|
4720
|
+
const MAX_PEEK_BYTES = 65536;
|
|
4721
|
+
const PEEK_TIMEOUT_MS = 1e4;
|
|
4722
|
+
const HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
4723
|
+
"connection",
|
|
4724
|
+
"host",
|
|
4725
|
+
"keep-alive",
|
|
4726
|
+
"proxy-authenticate",
|
|
4727
|
+
"proxy-authorization",
|
|
4728
|
+
"te",
|
|
4729
|
+
"trailer",
|
|
4730
|
+
"transfer-encoding",
|
|
4731
|
+
"upgrade"
|
|
4732
|
+
]);
|
|
4733
|
+
let proxyPromise;
|
|
4734
|
+
/** Load the egress CA. Each sandbox trusts it. */
|
|
4735
|
+
async function egressCaPem() {
|
|
4736
|
+
return (await loadEgressCa(dockerSandboxDir())).certPem;
|
|
4737
|
+
}
|
|
4738
|
+
/**
|
|
4739
|
+
* Return how the proxy handles a host, and the policy domain that it matched.
|
|
4740
|
+
* Upstream URLs use that domain, not the host name that the sandbox sent.
|
|
4741
|
+
*/
|
|
4742
|
+
function interceptMode(route, host) {
|
|
4743
|
+
const policy = route?.policy;
|
|
4744
|
+
const find = (domains) => (domains ?? []).find((domain) => matchesSandboxEgressDomain(host, domain));
|
|
4745
|
+
const credentialDomain = find(policy?.credentialEgress?.domains);
|
|
4746
|
+
if (credentialDomain) return {
|
|
4747
|
+
mode: "credential",
|
|
4748
|
+
domain: credentialDomain
|
|
4749
|
+
};
|
|
4750
|
+
const traceDomain = find(policy?.traceHeaders?.domains);
|
|
4751
|
+
if (traceDomain) return {
|
|
4752
|
+
mode: "trace",
|
|
4753
|
+
domain: traceDomain
|
|
4754
|
+
};
|
|
4755
|
+
}
|
|
4756
|
+
/**
|
|
4757
|
+
* Return the path and query of an origin-form request target.
|
|
4758
|
+
* An absolute or `//` target could name another host, so it is rejected.
|
|
4759
|
+
*/
|
|
4760
|
+
function originFormPath(target) {
|
|
4761
|
+
if (!target?.startsWith("/") || target.startsWith("//") || /[\r\n#]/.test(target)) return;
|
|
4762
|
+
const url = new URL(target, "http://sandbox-request.local");
|
|
4763
|
+
return `${url.pathname}${url.search}`;
|
|
4764
|
+
}
|
|
4765
|
+
/** Return the SNI host name of a complete TLS ClientHello record. */
|
|
4766
|
+
function parseSni(record) {
|
|
4767
|
+
let offset = 43;
|
|
4768
|
+
const read = (bytes) => {
|
|
4769
|
+
const value = record.readUIntBE(offset, bytes);
|
|
4770
|
+
offset += bytes;
|
|
4771
|
+
return value;
|
|
4772
|
+
};
|
|
4773
|
+
const skip = (lengthBytes) => {
|
|
4774
|
+
const length = read(lengthBytes);
|
|
4775
|
+
offset += length;
|
|
4776
|
+
};
|
|
4777
|
+
try {
|
|
4778
|
+
skip(1);
|
|
4779
|
+
skip(2);
|
|
4780
|
+
skip(1);
|
|
4781
|
+
const extensionsLength = read(2);
|
|
4782
|
+
const end = offset + extensionsLength;
|
|
4783
|
+
while (offset + 4 <= end) {
|
|
4784
|
+
const type = read(2);
|
|
4785
|
+
const length = read(2);
|
|
4786
|
+
if (type === 0) {
|
|
4787
|
+
offset += 3;
|
|
4788
|
+
const nameLength = read(2);
|
|
4789
|
+
return record.toString("ascii", offset, offset + nameLength);
|
|
4790
|
+
}
|
|
4791
|
+
offset += length;
|
|
4792
|
+
}
|
|
4793
|
+
} catch {}
|
|
4794
|
+
}
|
|
4795
|
+
/** Read until the first TLS record or HTTP request head is complete. */
|
|
4796
|
+
function peek(socket) {
|
|
4797
|
+
return new Promise((resolve, reject) => {
|
|
4798
|
+
let data = Buffer.alloc(0);
|
|
4799
|
+
const timer = setTimeout(() => finish(/* @__PURE__ */ new Error("peek timeout")), PEEK_TIMEOUT_MS);
|
|
4800
|
+
const finish = (error, host) => {
|
|
4801
|
+
clearTimeout(timer);
|
|
4802
|
+
socket.off("data", onData);
|
|
4803
|
+
socket.off("error", finish);
|
|
4804
|
+
socket.pause();
|
|
4805
|
+
if (error) reject(error);
|
|
4806
|
+
else resolve({
|
|
4807
|
+
data,
|
|
4808
|
+
host,
|
|
4809
|
+
scheme: data[0] === 22 ? "https" : "http"
|
|
4810
|
+
});
|
|
4811
|
+
};
|
|
4812
|
+
const onData = (chunk) => {
|
|
4813
|
+
data = Buffer.concat([data, chunk]);
|
|
4814
|
+
if (data[0] === 22) {
|
|
4815
|
+
if (data.length < 5) return;
|
|
4816
|
+
const end = 5 + data.readUInt16BE(3);
|
|
4817
|
+
if (data.length >= end) return finish(void 0, parseSni(data.subarray(0, end)));
|
|
4818
|
+
} else {
|
|
4819
|
+
const head = data.indexOf("\r\n\r\n");
|
|
4820
|
+
if (head >= 0) {
|
|
4821
|
+
const match = /^host:[ \t]*([^\r\n:]+)/im.exec(data.toString("latin1", 0, head));
|
|
4822
|
+
return finish(void 0, match?.[1]?.trim());
|
|
4823
|
+
}
|
|
4824
|
+
}
|
|
4825
|
+
if (data.length > MAX_PEEK_BYTES) finish(/* @__PURE__ */ new Error("request head too large"));
|
|
4826
|
+
};
|
|
4827
|
+
socket.on("data", onData);
|
|
4828
|
+
socket.once("error", finish);
|
|
4829
|
+
});
|
|
4830
|
+
}
|
|
4831
|
+
function requestHeaders$1(rawHeaders) {
|
|
4832
|
+
const headers = new Headers();
|
|
4833
|
+
for (let index = 0; index + 1 < rawHeaders.length; index += 2) {
|
|
4834
|
+
const name = rawHeaders[index] ?? "";
|
|
4835
|
+
if (HOP_BY_HOP.has(name.toLowerCase())) continue;
|
|
4836
|
+
headers.append(name, rawHeaders[index + 1] ?? "");
|
|
4837
|
+
}
|
|
4838
|
+
return headers;
|
|
4839
|
+
}
|
|
4840
|
+
async function writeResponse(response, res) {
|
|
4841
|
+
const headers = {};
|
|
4842
|
+
response.headers.forEach((value, name) => {
|
|
4843
|
+
if (name !== "set-cookie" && !HOP_BY_HOP.has(name)) headers[name] = value;
|
|
4844
|
+
});
|
|
4845
|
+
const cookies = response.headers.getSetCookie();
|
|
4846
|
+
if (cookies.length > 0) headers["set-cookie"] = cookies;
|
|
4847
|
+
res.writeHead(response.status, headers);
|
|
4848
|
+
if (!response.body) return void res.end();
|
|
4849
|
+
Readable.fromWeb(response.body).pipe(res);
|
|
4850
|
+
}
|
|
4851
|
+
/** Send a trace-domain request upstream with the policy's trace headers. */
|
|
4852
|
+
async function forwardWithTraceHeaders(request, route) {
|
|
4853
|
+
const headers = new Headers(request.headers);
|
|
4854
|
+
for (const [name, value] of Object.entries(route.policy.traceHeaders?.headers ?? {})) headers.set(name, value);
|
|
4855
|
+
const upstream = await fetch(request.url, {
|
|
4856
|
+
method: request.method,
|
|
4857
|
+
headers,
|
|
4858
|
+
body: request.body,
|
|
4859
|
+
redirect: "manual",
|
|
4860
|
+
duplex: "half"
|
|
4861
|
+
});
|
|
4862
|
+
const responseHeaders = new Headers(upstream.headers);
|
|
4863
|
+
responseHeaders.delete("content-encoding");
|
|
4864
|
+
responseHeaders.delete("content-length");
|
|
4865
|
+
return new Response(upstream.body, {
|
|
4866
|
+
status: upstream.status,
|
|
4867
|
+
statusText: upstream.statusText,
|
|
4868
|
+
headers: responseHeaders
|
|
4869
|
+
});
|
|
4870
|
+
}
|
|
4871
|
+
async function handleRequest(proxy, connection, req, res) {
|
|
4872
|
+
const route = proxy.routes.get(connection.sourceIp);
|
|
4873
|
+
const host = req.headers.host?.split(":")[0]?.toLowerCase();
|
|
4874
|
+
if (host !== connection.host) {
|
|
4875
|
+
res.writeHead(421).end("Host does not match the TLS server name");
|
|
4876
|
+
return;
|
|
4877
|
+
}
|
|
4878
|
+
const intercept = interceptMode(route, host);
|
|
4879
|
+
if (!route || !intercept) {
|
|
4880
|
+
res.writeHead(403).end("Sandbox egress route is gone");
|
|
4881
|
+
return;
|
|
4882
|
+
}
|
|
4883
|
+
if (intercept.mode === "credential" && connection.scheme !== "https") {
|
|
4884
|
+
res.writeHead(403).end("Credentialed egress requires HTTPS");
|
|
4885
|
+
return;
|
|
4886
|
+
}
|
|
4887
|
+
const path = originFormPath(req.url);
|
|
4888
|
+
if (!path) {
|
|
4889
|
+
res.writeHead(400).end("Request target must be a path");
|
|
4890
|
+
return;
|
|
4891
|
+
}
|
|
4892
|
+
const upstreamUrl = new URL(`${connection.scheme}://${intercept.domain}${path}`);
|
|
4893
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
4894
|
+
const request = new Request(upstreamUrl, {
|
|
4895
|
+
method: req.method,
|
|
4896
|
+
headers: requestHeaders$1(req.rawHeaders),
|
|
4897
|
+
...hasBody ? {
|
|
4898
|
+
body: Readable.toWeb(req),
|
|
4899
|
+
duplex: "half"
|
|
4900
|
+
} : void 0
|
|
4901
|
+
});
|
|
4902
|
+
await writeResponse(intercept.mode === "credential" ? await executeSandboxEgressRequest({
|
|
4903
|
+
request,
|
|
4904
|
+
egressId: route.egressId,
|
|
4905
|
+
upstreamUrl,
|
|
4906
|
+
credentialToken: route.policy.credentialEgress?.credentialToken
|
|
4907
|
+
}) : await forwardWithTraceHeaders(request, route), res);
|
|
4908
|
+
}
|
|
4909
|
+
function secureContext(proxy, host) {
|
|
4910
|
+
let context = proxy.contexts.get(host);
|
|
4911
|
+
if (!context) {
|
|
4912
|
+
context = tls.createSecureContext({
|
|
4913
|
+
key: proxy.leafKey.export({
|
|
4914
|
+
type: "pkcs8",
|
|
4915
|
+
format: "pem"
|
|
4916
|
+
}),
|
|
4917
|
+
cert: issueLeafCertificate(proxy.ca, host, proxy.leafKey)
|
|
4918
|
+
});
|
|
4919
|
+
proxy.contexts.set(host, context);
|
|
4920
|
+
}
|
|
4921
|
+
return context;
|
|
4922
|
+
}
|
|
4923
|
+
/**
|
|
4924
|
+
* Check that the route's container still has this address on its own network.
|
|
4925
|
+
* A removed sandbox's address can come back on a new sandbox network.
|
|
4926
|
+
*/
|
|
4927
|
+
async function ownsAddress(route, sourceIp) {
|
|
4928
|
+
try {
|
|
4929
|
+
const details = await dockerJson({ path: `/containers/${route.egressId}/json` });
|
|
4930
|
+
return details.State.Running && details.NetworkSettings.Networks[route.name]?.IPAddress === sourceIp;
|
|
4931
|
+
} catch {
|
|
4932
|
+
return false;
|
|
4933
|
+
}
|
|
4934
|
+
}
|
|
4935
|
+
/**
|
|
4936
|
+
* Wrap a socket in a stream that first replays the bytes that `peek` read.
|
|
4937
|
+
* TLS and HTTP servers read a net.Socket's handle directly, so they would skip
|
|
4938
|
+
* bytes put back with `unshift`.
|
|
4939
|
+
*/
|
|
4940
|
+
function replay(socket, data) {
|
|
4941
|
+
const stream = new Duplex({
|
|
4942
|
+
read() {
|
|
4943
|
+
socket.resume();
|
|
4944
|
+
},
|
|
4945
|
+
write(chunk, encoding, callback) {
|
|
4946
|
+
socket.write(chunk, encoding, callback);
|
|
4947
|
+
},
|
|
4948
|
+
final(callback) {
|
|
4949
|
+
socket.end(callback);
|
|
4950
|
+
},
|
|
4951
|
+
destroy(error, callback) {
|
|
4952
|
+
socket.destroy(error ?? void 0);
|
|
4953
|
+
callback(error);
|
|
4954
|
+
}
|
|
4955
|
+
});
|
|
4956
|
+
stream.push(data);
|
|
4957
|
+
socket.on("data", (chunk) => {
|
|
4958
|
+
if (!stream.push(chunk)) socket.pause();
|
|
4959
|
+
});
|
|
4960
|
+
socket.on("end", () => stream.push(null));
|
|
4961
|
+
socket.on("error", (error) => stream.destroy(error));
|
|
4962
|
+
return stream;
|
|
4963
|
+
}
|
|
4964
|
+
/**
|
|
4965
|
+
* Resolve a pass-through host once and return its address. Return undefined
|
|
4966
|
+
* when any address is not public. The caller connects to this address, so a
|
|
4967
|
+
* second DNS answer cannot change the target.
|
|
4968
|
+
*/
|
|
4969
|
+
async function publicAddress(host) {
|
|
4970
|
+
const records = await dns.lookup(host, {
|
|
4971
|
+
all: true,
|
|
4972
|
+
verbatim: true
|
|
4973
|
+
}).catch(() => []);
|
|
4974
|
+
return records.some((record) => BLOCKED_PASS_THROUGH.check(record.address, record.family === 6 ? "ipv6" : "ipv4")) ? void 0 : records[0]?.address;
|
|
4975
|
+
}
|
|
4976
|
+
function passThrough(socket, data, host, port) {
|
|
4977
|
+
const upstream = net.connect({
|
|
4978
|
+
host,
|
|
4979
|
+
port
|
|
4980
|
+
}, () => {
|
|
4981
|
+
upstream.write(data);
|
|
4982
|
+
socket.pipe(upstream);
|
|
4983
|
+
upstream.pipe(socket);
|
|
4984
|
+
socket.resume();
|
|
4985
|
+
});
|
|
4986
|
+
upstream.on("error", () => socket.destroy());
|
|
4987
|
+
socket.on("error", () => upstream.destroy());
|
|
4988
|
+
}
|
|
4989
|
+
/** Return the sandbox address of a direct connection from a sandbox. */
|
|
4990
|
+
function directSource(socket) {
|
|
4991
|
+
return (socket.remoteAddress ?? "").replace(/^::ffff:/, "");
|
|
4992
|
+
}
|
|
4993
|
+
/**
|
|
4994
|
+
* Return the sandbox address of a relay connection. The client certificate
|
|
4995
|
+
* names the sandbox's container id.
|
|
4996
|
+
*/
|
|
4997
|
+
function relaySource(proxy, socket) {
|
|
4998
|
+
const egressId = socket.getPeerCertificate().subject?.CN;
|
|
4999
|
+
for (const [sourceIp, route] of proxy.routes) if (route.egressId === egressId) return sourceIp;
|
|
5000
|
+
return "";
|
|
5001
|
+
}
|
|
5002
|
+
async function handleConnection(proxy, server, connections, socket, sourceIp) {
|
|
5003
|
+
const route = proxy.routes.get(sourceIp);
|
|
5004
|
+
if (!route || !await ownsAddress(route, sourceIp)) {
|
|
5005
|
+
logWarn("sandbox.egress.docker.unknown_source", { "client.address": socket.remoteAddress ?? "" });
|
|
5006
|
+
socket.destroy();
|
|
5007
|
+
return;
|
|
5008
|
+
}
|
|
5009
|
+
const { data, host: rawHost, scheme } = await peek(socket);
|
|
5010
|
+
const host = rawHost?.toLowerCase();
|
|
5011
|
+
if (!host) {
|
|
5012
|
+
socket.destroy();
|
|
5013
|
+
return;
|
|
5014
|
+
}
|
|
5015
|
+
if (!interceptMode(route, host)) {
|
|
5016
|
+
const address = await publicAddress(host);
|
|
5017
|
+
if (!address) {
|
|
5018
|
+
logWarn("sandbox.egress.docker.pass_through_blocked", { "server.address": host });
|
|
5019
|
+
socket.destroy();
|
|
5020
|
+
return;
|
|
5021
|
+
}
|
|
5022
|
+
passThrough(socket, data, address, scheme === "https" ? 443 : 80);
|
|
5023
|
+
return;
|
|
5024
|
+
}
|
|
5025
|
+
const replayed = replay(socket, data);
|
|
5026
|
+
const target = scheme === "https" ? new tls.TLSSocket(replayed, {
|
|
5027
|
+
isServer: true,
|
|
5028
|
+
secureContext: secureContext(proxy, host),
|
|
5029
|
+
ALPNProtocols: ["http/1.1"]
|
|
5030
|
+
}) : replayed;
|
|
5031
|
+
connections.set(target, {
|
|
5032
|
+
sourceIp,
|
|
5033
|
+
host,
|
|
5034
|
+
scheme
|
|
5035
|
+
});
|
|
5036
|
+
target.on("error", () => socket.destroy());
|
|
5037
|
+
server.emit("connection", target);
|
|
5038
|
+
}
|
|
5039
|
+
async function startProxy() {
|
|
5040
|
+
const proxy = {
|
|
5041
|
+
port: 0,
|
|
5042
|
+
ca: await loadEgressCa(dockerSandboxDir()),
|
|
5043
|
+
leafKey: createLeafKey(),
|
|
5044
|
+
contexts: /* @__PURE__ */ new Map(),
|
|
5045
|
+
routes: /* @__PURE__ */ new Map()
|
|
5046
|
+
};
|
|
5047
|
+
const connections = /* @__PURE__ */ new WeakMap();
|
|
5048
|
+
const server = http.createServer((req, res) => {
|
|
5049
|
+
const connection = connections.get(req.socket);
|
|
5050
|
+
if (!connection) return void res.writeHead(500).end();
|
|
5051
|
+
handleRequest(proxy, connection, req, res).catch((error) => {
|
|
5052
|
+
logWarn("sandbox.egress.docker.request_failed", { "error.message": error instanceof Error ? error.message : String(error) });
|
|
5053
|
+
if (!res.headersSent) res.writeHead(502);
|
|
5054
|
+
res.end();
|
|
5055
|
+
});
|
|
5056
|
+
});
|
|
5057
|
+
const handle = (socket, sourceIp) => handleConnection(proxy, server, connections, socket, sourceIp).catch(() => socket.destroy());
|
|
5058
|
+
const listener = usesEgressRelay() ? tls.createServer({
|
|
5059
|
+
key: proxy.leafKey.export({
|
|
5060
|
+
type: "pkcs8",
|
|
5061
|
+
format: "pem"
|
|
5062
|
+
}),
|
|
5063
|
+
cert: issueLeafCertificate(proxy.ca, RELAY_PROXY_HOST, proxy.leafKey),
|
|
5064
|
+
ca: proxy.ca.certPem,
|
|
5065
|
+
requestCert: true,
|
|
5066
|
+
rejectUnauthorized: true
|
|
5067
|
+
}, (socket) => void handle(socket, relaySource(proxy, socket))) : net.createServer((socket) => void handle(socket, directSource(socket)));
|
|
5068
|
+
await new Promise((resolve, reject) => {
|
|
5069
|
+
listener.once("error", reject);
|
|
5070
|
+
listener.listen(0, "0.0.0.0", () => resolve());
|
|
5071
|
+
});
|
|
5072
|
+
listener.unref();
|
|
5073
|
+
proxy.port = listener.address().port;
|
|
5074
|
+
return proxy;
|
|
5075
|
+
}
|
|
5076
|
+
function getProxy() {
|
|
5077
|
+
proxyPromise ??= startProxy().catch((error) => {
|
|
5078
|
+
proxyPromise = void 0;
|
|
5079
|
+
throw error;
|
|
5080
|
+
});
|
|
5081
|
+
return proxyPromise;
|
|
5082
|
+
}
|
|
5083
|
+
function rulesScript(target) {
|
|
5084
|
+
const lines = [
|
|
5085
|
+
"set -eu",
|
|
5086
|
+
`iptables -t nat -N ${CHAIN} 2>/dev/null || iptables -t nat -F ${CHAIN}`,
|
|
5087
|
+
`iptables -t nat -C OUTPUT -j ${CHAIN} 2>/dev/null || iptables -t nat -A OUTPUT -j ${CHAIN}`,
|
|
5088
|
+
`iptables -t nat -C PREROUTING ! -i eth0 -j ${CHAIN} 2>/dev/null || iptables -t nat -A PREROUTING ! -i eth0 -j ${CHAIN}`
|
|
5089
|
+
];
|
|
5090
|
+
if (target) {
|
|
5091
|
+
for (const range of DIRECT_RANGES) lines.push(`iptables -t nat -A ${CHAIN} -d ${range} -j RETURN`);
|
|
5092
|
+
for (const port of [80, 443]) lines.push(`iptables -t nat -A ${CHAIN} -p tcp --dport ${port} -j DNAT --to-destination ${target}`);
|
|
5093
|
+
}
|
|
5094
|
+
return lines.join("\n");
|
|
5095
|
+
}
|
|
5096
|
+
function hasDomains(policy) {
|
|
5097
|
+
return Boolean(policy.credentialEgress?.domains.length || policy.traceHeaders?.domains.length);
|
|
5098
|
+
}
|
|
5099
|
+
/**
|
|
5100
|
+
* Apply one network policy to a sandbox container.
|
|
5101
|
+
*
|
|
5102
|
+
* A policy with credentialed or trace domains sends the container's web
|
|
5103
|
+
* traffic to this process's proxy. `allow-all` removes the redirect.
|
|
5104
|
+
*/
|
|
5105
|
+
async function applyEgressPolicy(container, policy) {
|
|
5106
|
+
let target;
|
|
5107
|
+
if (policy !== "allow-all" && hasDomains(policy)) {
|
|
5108
|
+
const proxy = await getProxy();
|
|
5109
|
+
proxy.routes.set(container.ip, {
|
|
5110
|
+
egressId: container.id,
|
|
5111
|
+
name: container.name,
|
|
5112
|
+
policy
|
|
5113
|
+
});
|
|
5114
|
+
target = usesEgressRelay() ? await ensureEgressRelay(container, proxy.ca, proxy.port) : `${container.gateway}:${proxy.port}`;
|
|
5115
|
+
} else if (proxyPromise) (await proxyPromise).routes.delete(container.ip);
|
|
5116
|
+
const result = await runRawExec(container.id, {
|
|
5117
|
+
cmd: [
|
|
5118
|
+
"sh",
|
|
5119
|
+
"-c",
|
|
5120
|
+
rulesScript(target)
|
|
5121
|
+
],
|
|
5122
|
+
root: true
|
|
5123
|
+
});
|
|
5124
|
+
if (result.exitCode !== 0) throw new Error(`Could not apply the sandbox network policy: ${result.stderr.trim()}`);
|
|
5125
|
+
}
|
|
5126
|
+
// src/chat/sandbox/http-error-details.ts
|
|
5127
|
+
const DEFAULT_PREVIEW_LIMIT = 512;
|
|
5128
|
+
function toTrimmedString(value, maxChars) {
|
|
5129
|
+
if (typeof value !== "string") return;
|
|
5130
|
+
const normalized = value.trim();
|
|
5131
|
+
if (!normalized) return;
|
|
5132
|
+
return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}…` : normalized;
|
|
5133
|
+
}
|
|
5134
|
+
/** Extract structured HTTP error metadata (status, headers, body preview) for logging. */
|
|
5135
|
+
function extractHttpErrorDetails(error, options = {}) {
|
|
5136
|
+
const prefix = options.attributePrefix ?? "app.http_error";
|
|
5137
|
+
const previewLimit = options.previewLimit ?? DEFAULT_PREVIEW_LIMIT;
|
|
5138
|
+
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
5139
|
+
const err = error ?? {};
|
|
5140
|
+
const attributes = {
|
|
5141
|
+
"error.type": normalizedError.name || "Error",
|
|
5142
|
+
"exception.message": toTrimmedString(normalizedError.message, previewLimit) ?? "HTTP error"
|
|
5143
|
+
};
|
|
5144
|
+
const response = err.response;
|
|
5145
|
+
const statusCode = typeof response?.status === "number" ? response.status : void 0;
|
|
5146
|
+
const statusText = toTrimmedString(response?.statusText, previewLimit);
|
|
5147
|
+
const responseUrl = toTrimmedString(response?.url, previewLimit);
|
|
5148
|
+
const responseText = toTrimmedString(err.text, previewLimit);
|
|
5149
|
+
const responseJson = toTrimmedString(err.json && typeof err.json === "object" ? JSON.stringify(err.json) : void 0, previewLimit);
|
|
5150
|
+
const contentType = toTrimmedString(response?.headers?.get?.("content-type"), previewLimit);
|
|
5151
|
+
const requestIdHeader = toTrimmedString(response?.headers?.get?.("x-request-id"), previewLimit);
|
|
5152
|
+
const vercelIdHeader = toTrimmedString(response?.headers?.get?.("x-vercel-id"), previewLimit);
|
|
5153
|
+
const requestId = requestIdHeader ?? vercelIdHeader;
|
|
5154
|
+
if (statusCode !== void 0) attributes["http.response.status_code"] = statusCode;
|
|
5155
|
+
if (statusText) attributes[`${prefix}.status_text`] = statusText;
|
|
5156
|
+
if (responseUrl) attributes["url.full"] = responseUrl;
|
|
5157
|
+
if (contentType) attributes["http.response.header.content-type"] = [contentType];
|
|
5158
|
+
if (requestIdHeader) attributes["http.response.header.x-request-id"] = [requestIdHeader];
|
|
5159
|
+
if (vercelIdHeader) attributes["http.response.header.x-vercel-id"] = [vercelIdHeader];
|
|
5160
|
+
if (responseText) {
|
|
5161
|
+
attributes[`${prefix}.response_text_preview`] = responseText;
|
|
5162
|
+
attributes[`${prefix}.response_text_length`] = responseText.length;
|
|
5163
|
+
}
|
|
5164
|
+
if (responseJson) attributes[`${prefix}.response_json_preview`] = responseJson;
|
|
5165
|
+
const summaryParts = [];
|
|
5166
|
+
if (statusCode !== void 0) summaryParts.push(`status=${statusCode}`);
|
|
5167
|
+
if (statusText) summaryParts.push(`statusText=${statusText}`);
|
|
5168
|
+
if (responseUrl) summaryParts.push(`url=${responseUrl}`);
|
|
5169
|
+
if (requestId) summaryParts.push(`requestId=${requestId}`);
|
|
5170
|
+
for (const field of options.extraFields ?? []) {
|
|
5171
|
+
const value = toTrimmedString(err[field.sourceKey], previewLimit);
|
|
5172
|
+
if (!value) continue;
|
|
5173
|
+
attributes[`${prefix}.${field.attributeKey}`] = value;
|
|
5174
|
+
summaryParts.push(`${field.summaryKey ?? field.attributeKey}=${value}`);
|
|
5175
|
+
}
|
|
5176
|
+
if (responseJson) summaryParts.push(`json=${responseJson}`);
|
|
5177
|
+
else if (responseText) summaryParts.push(`text=${responseText}`);
|
|
5178
|
+
const searchableText = `${normalizedError.message} ${responseText ?? ""} ${responseJson ?? ""}`.toLowerCase();
|
|
5179
|
+
return {
|
|
5180
|
+
attributes,
|
|
5181
|
+
summary: summaryParts.join(", "),
|
|
5182
|
+
searchableText
|
|
5183
|
+
};
|
|
5184
|
+
}
|
|
5185
|
+
// src/chat/sandbox/snapshot/not-ready-error.ts
|
|
5186
|
+
/** Report that a Workspace snapshot is not ready to use. */
|
|
5187
|
+
var WorkspaceSnapshotNotReadyError = class extends Error {
|
|
5188
|
+
workspaceName;
|
|
5189
|
+
constructor(workspaceName) {
|
|
5190
|
+
super(`Workspace ${workspaceName} snapshot is not ready`);
|
|
5191
|
+
this.name = "WorkspaceSnapshotNotReadyError";
|
|
5192
|
+
this.workspaceName = workspaceName;
|
|
5193
|
+
}
|
|
5194
|
+
};
|
|
5195
|
+
/** Return WorkspaceSnapshotNotReadyError from this error or its causes. */
|
|
5196
|
+
function getWorkspaceSnapshotNotReadyError(error) {
|
|
5197
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5198
|
+
let current = error;
|
|
5199
|
+
while (current instanceof Error && !seen.has(current)) {
|
|
5200
|
+
if (current instanceof WorkspaceSnapshotNotReadyError) return current;
|
|
5201
|
+
seen.add(current);
|
|
5202
|
+
current = current.cause;
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
5205
|
+
/** True when this error or a cause is WorkspaceSnapshotNotReadyError. */
|
|
5206
|
+
function isWorkspaceSnapshotNotReadyError(error) {
|
|
5207
|
+
return getWorkspaceSnapshotNotReadyError(error) !== void 0;
|
|
5208
|
+
}
|
|
5209
|
+
/** User message when a Workspace sandbox is still preparing. */
|
|
5210
|
+
function workspaceSnapshotNotReadyUserMessage(error) {
|
|
5211
|
+
return `The ${error.workspaceName} workspace is still preparing its sandbox. Wait for that preparation to finish, then try again.`;
|
|
5212
|
+
}
|
|
5213
|
+
// src/chat/sandbox/errors.ts
|
|
5214
|
+
const SANDBOX_ERROR_FIELDS = [{
|
|
5215
|
+
sourceKey: "sandboxId",
|
|
5216
|
+
attributeKey: "sandbox_id",
|
|
5217
|
+
summaryKey: "sandboxId"
|
|
5218
|
+
}];
|
|
5219
|
+
/** Extract HTTP error details from a sandbox error for span data and checks. */
|
|
5220
|
+
function getSandboxErrorDetails(error) {
|
|
5221
|
+
return extractHttpErrorDetails(error, {
|
|
5222
|
+
attributePrefix: "app.sandbox.api_error",
|
|
5223
|
+
extraFields: [...SANDBOX_ERROR_FIELDS]
|
|
5224
|
+
});
|
|
5225
|
+
}
|
|
5226
|
+
/** Return true when any error in the `cause` chain matches the predicate. */
|
|
5227
|
+
function findInErrorChain(error, predicate) {
|
|
5228
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5229
|
+
let current = error;
|
|
5230
|
+
while (current && !seen.has(current)) {
|
|
5231
|
+
if (predicate(current)) return true;
|
|
5232
|
+
seen.add(current);
|
|
5233
|
+
current = typeof current === "object" ? current.cause : void 0;
|
|
5234
|
+
}
|
|
5235
|
+
return false;
|
|
5236
|
+
}
|
|
5237
|
+
function getFirstErrorMessage(error) {
|
|
5238
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5239
|
+
let current = error;
|
|
5240
|
+
while (current && !seen.has(current)) {
|
|
5241
|
+
if (current instanceof Error) {
|
|
5242
|
+
const message = current.message.trim();
|
|
5243
|
+
if (message) return message;
|
|
5244
|
+
}
|
|
5245
|
+
seen.add(current);
|
|
5246
|
+
current = typeof current === "object" ? current.cause : void 0;
|
|
5247
|
+
}
|
|
5248
|
+
}
|
|
5249
|
+
/** Detect sandbox filesystem operations that can safely ignore existing directories. */
|
|
5250
|
+
function isAlreadyExistsError(error) {
|
|
5251
|
+
const details = getSandboxErrorDetails(error);
|
|
5252
|
+
return details.searchableText.includes("already exists") || details.searchableText.includes("file exists") || details.searchableText.includes("eexist");
|
|
5253
|
+
}
|
|
5254
|
+
/** Detect cancellation so a durable builder stays available for check-in. */
|
|
5255
|
+
function isAbortError(error) {
|
|
5256
|
+
return findInErrorChain(error, (candidate) => {
|
|
5257
|
+
if (typeof DOMException !== "undefined" && candidate instanceof DOMException) return candidate.name === "AbortError";
|
|
5258
|
+
return candidate instanceof Error && candidate.name === "AbortError";
|
|
5259
|
+
});
|
|
5260
|
+
}
|
|
5261
|
+
/** Wrap setup failures. Keep WorkspaceSnapshotNotReadyError as-is. */
|
|
5262
|
+
function wrapSandboxSetupError(error) {
|
|
5263
|
+
const notReady = getWorkspaceSnapshotNotReadyError(error);
|
|
5264
|
+
if (notReady) return notReady;
|
|
5265
|
+
try {
|
|
5266
|
+
const details = getSandboxErrorDetails(error);
|
|
5267
|
+
if (details.summary) return new Error(`sandbox setup failed (${details.summary})`, { cause: error });
|
|
5268
|
+
} catch {}
|
|
5269
|
+
let causeMessage;
|
|
5270
|
+
try {
|
|
5271
|
+
causeMessage = getFirstErrorMessage(error);
|
|
5272
|
+
} catch (cause) {
|
|
5273
|
+
causeMessage = cause instanceof Error ? cause.message : void 0;
|
|
5274
|
+
}
|
|
5275
|
+
if (causeMessage && causeMessage.trim() && causeMessage !== "sandbox setup failed") {
|
|
5276
|
+
const oneLine = causeMessage.replace(/\s+/g, " ").trim();
|
|
5277
|
+
return new Error(`sandbox setup failed (${oneLine})`, { cause: error });
|
|
5278
|
+
}
|
|
5279
|
+
return new Error("sandbox setup failed", { cause: error });
|
|
5280
|
+
}
|
|
5281
|
+
/** Record span data and throw one stable sandbox operation error. */
|
|
5282
|
+
function throwSandboxOperationError(action, error, includeMissingPath = false) {
|
|
5283
|
+
const details = getSandboxErrorDetails(error);
|
|
5284
|
+
setSpanAttributes({
|
|
5285
|
+
...details.attributes,
|
|
5286
|
+
...includeMissingPath ? { "app.sandbox.api_error.missing_path": details.searchableText.includes("no such file") || details.searchableText.includes("enoent") } : void 0,
|
|
5287
|
+
"app.sandbox.success": false
|
|
5288
|
+
});
|
|
5289
|
+
throw new Error(details.summary ? `${action} failed (${details.summary})` : `${action} failed`, { cause: error });
|
|
5290
|
+
}
|
|
5291
|
+
// src/chat/sandbox/docker/errors.ts
|
|
5292
|
+
function apiError(error, match) {
|
|
5293
|
+
return findInErrorChain(error, (candidate) => candidate instanceof DockerApiError && match(candidate));
|
|
5294
|
+
}
|
|
5295
|
+
function isMissingContainer(candidate) {
|
|
5296
|
+
return candidate.statusCode === 404 && /no such container/i.test(candidate.message) || candidate.statusCode === 409 && /marked for removal|removal .*in progress/i.test(candidate.message);
|
|
5297
|
+
}
|
|
5298
|
+
function isUnavailable$1(error) {
|
|
5299
|
+
return apiError(error, (candidate) => isMissingContainer(candidate) || candidate.statusCode === 409 && /is not running|is paused|restarting/i.test(candidate.message));
|
|
5300
|
+
}
|
|
5301
|
+
function isMissing$1(error) {
|
|
5302
|
+
return apiError(error, isMissingContainer);
|
|
5303
|
+
}
|
|
5304
|
+
function isSnapshotMissing$1(error) {
|
|
5305
|
+
return apiError(error, (candidate) => candidate.statusCode === 404 && /no such image/i.test(candidate.message));
|
|
5306
|
+
}
|
|
5307
|
+
function isTransient$1(error) {
|
|
5308
|
+
return findInErrorChain(error, (candidate) => {
|
|
5309
|
+
const code = candidate?.code;
|
|
5310
|
+
return code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EPIPE";
|
|
5311
|
+
});
|
|
5312
|
+
}
|
|
5313
|
+
/** Map Docker Engine API errors to the shared sandbox error cases. */
|
|
5314
|
+
const dockerSandboxErrors = {
|
|
5315
|
+
isUnavailable: isUnavailable$1,
|
|
5316
|
+
isMissing: isMissing$1,
|
|
5317
|
+
isSnapshotMissing: isSnapshotMissing$1,
|
|
5318
|
+
isSnapshotting: () => false,
|
|
5319
|
+
isTransient: isTransient$1,
|
|
5320
|
+
isCommandStreamInterrupted: () => false
|
|
5321
|
+
};
|
|
5322
|
+
// src/chat/sandbox/docker/session.ts
|
|
5323
|
+
/** Image repository for every snapshot that this provider captures. */
|
|
5324
|
+
const SNAPSHOT_REPOSITORY = "junior-sandbox-snapshot";
|
|
5325
|
+
/** Go's `os.ModeDir` bit in Docker's path stat header. */
|
|
5326
|
+
const MODE_DIR = 2 ** 31;
|
|
5327
|
+
function absolute(filePath, cwd) {
|
|
5328
|
+
return path.posix.resolve(cwd ?? SANDBOX_WORKSPACE_ROOT, filePath);
|
|
5329
|
+
}
|
|
5330
|
+
function notFound(filePath) {
|
|
5331
|
+
return Object.assign(/* @__PURE__ */ new Error(`ENOENT: no such file or directory, '${filePath}'`), { code: "ENOENT" });
|
|
5332
|
+
}
|
|
5333
|
+
async function readFile$1(id, filePath, signal) {
|
|
5334
|
+
try {
|
|
5335
|
+
return unpackFirstFile(await dockerRequest({
|
|
5336
|
+
path: `/containers/${id}/archive`,
|
|
5337
|
+
query: { path: filePath },
|
|
5338
|
+
signal
|
|
5339
|
+
}));
|
|
5340
|
+
} catch (error) {
|
|
5341
|
+
if (error instanceof DockerApiError && error.statusCode === 404) return null;
|
|
5342
|
+
throw error;
|
|
5343
|
+
}
|
|
5344
|
+
}
|
|
5345
|
+
async function writeFiles(id, files, signal) {
|
|
5346
|
+
const entries = files.map((file) => ({
|
|
5347
|
+
path: absolute(file.path),
|
|
5348
|
+
content: typeof file.content === "string" ? Buffer.from(file.content) : file.content,
|
|
5349
|
+
mode: file.mode ?? 420
|
|
5350
|
+
}));
|
|
5351
|
+
await runRawExec(id, { cmd: [
|
|
5352
|
+
"mkdir",
|
|
5353
|
+
"-p",
|
|
5354
|
+
...[...new Set(entries.map((entry) => path.posix.dirname(entry.path)))]
|
|
5355
|
+
] }, signal);
|
|
5356
|
+
await dockerRequest({
|
|
5357
|
+
method: "PUT",
|
|
5358
|
+
path: `/containers/${id}/archive`,
|
|
5359
|
+
query: { path: "/" },
|
|
5360
|
+
body: packTar(entries.map((entry) => ({
|
|
5361
|
+
...entry,
|
|
5362
|
+
path: entry.path.replace(/^\/+/, ""),
|
|
5363
|
+
uid: SANDBOX_FILE_OWNER.uid,
|
|
5364
|
+
gid: SANDBOX_FILE_OWNER.gid
|
|
5365
|
+
}))),
|
|
5366
|
+
signal
|
|
5367
|
+
});
|
|
5368
|
+
}
|
|
5369
|
+
async function statMode(id, filePath, signal) {
|
|
5370
|
+
const response = await dockerStream({
|
|
5371
|
+
method: "HEAD",
|
|
5372
|
+
path: `/containers/${id}/archive`,
|
|
5373
|
+
query: { path: filePath },
|
|
5374
|
+
signal
|
|
5375
|
+
});
|
|
5376
|
+
response.resume();
|
|
5377
|
+
if (response.statusCode === 404) return null;
|
|
5378
|
+
const header = response.headers["x-docker-container-path-stat"];
|
|
5379
|
+
if ((response.statusCode ?? 0) >= 400 || typeof header !== "string") throw new DockerApiError(response.statusCode ?? 0, `stat ${filePath} failed`);
|
|
5380
|
+
return JSON.parse(Buffer.from(header, "base64").toString("utf8")).mode;
|
|
5381
|
+
}
|
|
5382
|
+
function fileSystem(id) {
|
|
5383
|
+
return {
|
|
5384
|
+
async readFile(filePath, options) {
|
|
5385
|
+
const target = absolute(filePath);
|
|
5386
|
+
const content = await readFile$1(id, target, options.signal);
|
|
5387
|
+
if (!content) throw notFound(target);
|
|
5388
|
+
return content.toString(options.encoding);
|
|
5389
|
+
},
|
|
5390
|
+
async writeFile(filePath, content, options) {
|
|
5391
|
+
await writeFiles(id, [{
|
|
5392
|
+
path: filePath,
|
|
5393
|
+
content: Buffer.from(content, options?.encoding ?? "utf8")
|
|
5394
|
+
}], options?.signal);
|
|
5395
|
+
},
|
|
5396
|
+
async readdir(filePath, options) {
|
|
5397
|
+
const target = absolute(filePath);
|
|
5398
|
+
const result = await runRawExec(id, { cmd: [
|
|
5399
|
+
"find",
|
|
5400
|
+
target,
|
|
5401
|
+
"-mindepth",
|
|
5402
|
+
"1",
|
|
5403
|
+
"-maxdepth",
|
|
5404
|
+
"1",
|
|
5405
|
+
"-printf",
|
|
5406
|
+
"%f\\0"
|
|
5407
|
+
] }, options?.signal);
|
|
5408
|
+
if (result.exitCode !== 0) throw notFound(target);
|
|
5409
|
+
return result.stdout.split("\0").filter(Boolean);
|
|
5410
|
+
},
|
|
5411
|
+
async stat(filePath, options) {
|
|
5412
|
+
const target = absolute(filePath);
|
|
5413
|
+
const mode = await statMode(id, target, options?.signal);
|
|
5414
|
+
if (mode === null) throw notFound(target);
|
|
5415
|
+
return { isDirectory: () => mode >= MODE_DIR };
|
|
5416
|
+
}
|
|
5417
|
+
};
|
|
5418
|
+
}
|
|
5419
|
+
/** Commit the container to a local image and return the image id. */
|
|
5420
|
+
async function commitSnapshot(id, tag, signal) {
|
|
5421
|
+
return (await dockerJson({
|
|
5422
|
+
method: "POST",
|
|
5423
|
+
path: "/commit",
|
|
5424
|
+
query: {
|
|
5425
|
+
container: id,
|
|
5426
|
+
repo: SNAPSHOT_REPOSITORY,
|
|
5427
|
+
tag,
|
|
5428
|
+
changes: `LABEL ${DOCKER_SANDBOX_LABEL}=snapshot`
|
|
5429
|
+
},
|
|
5430
|
+
signal
|
|
5431
|
+
})).Id;
|
|
5432
|
+
}
|
|
5433
|
+
/** Junior's sandbox session contract for one running sandbox container. */
|
|
5434
|
+
function adaptSession$1(container, options) {
|
|
5435
|
+
const { id } = container;
|
|
5436
|
+
const run = async (operation) => {
|
|
5437
|
+
options?.scopeSignal?.throwIfAborted();
|
|
5438
|
+
try {
|
|
5439
|
+
return await operation();
|
|
5440
|
+
} catch (error) {
|
|
5441
|
+
if (dockerSandboxErrors.isUnavailable(error)) options?.onUnavailable?.(id);
|
|
5442
|
+
throw error;
|
|
5443
|
+
}
|
|
5444
|
+
};
|
|
5445
|
+
const fs = fileSystem(id);
|
|
5446
|
+
return {
|
|
5447
|
+
sandboxId: container.name,
|
|
5448
|
+
sessionId: id,
|
|
5449
|
+
fs: {
|
|
5450
|
+
readFile: (filePath, readOptions) => run(() => fs.readFile(filePath, readOptions)),
|
|
5451
|
+
writeFile: (filePath, content, writeOptions) => run(() => fs.writeFile(filePath, content, writeOptions)),
|
|
5452
|
+
readdir: (filePath, readOptions) => run(() => fs.readdir(filePath, readOptions)),
|
|
5453
|
+
stat: (filePath, statOptions) => run(() => fs.stat(filePath, statOptions))
|
|
5454
|
+
},
|
|
5455
|
+
extendTimeout: (duration) => run(() => extendDeadline(id, duration)),
|
|
5456
|
+
mkDir: (dirPath) => run(async () => {
|
|
5457
|
+
const target = absolute(dirPath);
|
|
5458
|
+
const result = await runRawExec(id, { cmd: ["mkdir", target] });
|
|
5459
|
+
if (result.exitCode !== 0) throw new Error(`mkdir ${target} failed: ${result.stderr.trim()}`);
|
|
5460
|
+
}),
|
|
5461
|
+
readFileToBuffer: (input) => run(() => readFile$1(id, absolute(input.path, input.cwd))),
|
|
5462
|
+
runCommand: (input) => run(() => runSandboxCommand(id, {
|
|
5463
|
+
...input,
|
|
5464
|
+
cwd: input.cwd ?? SANDBOX_WORKSPACE_ROOT,
|
|
5465
|
+
signal: input.signal ?? options?.scopeSignal
|
|
5466
|
+
})),
|
|
5467
|
+
snapshot: (snapshotOptions) => run(async () => ({ snapshotId: await commitSnapshot(id, container.name, snapshotOptions?.signal) })),
|
|
5468
|
+
stop: () => run(() => stopContainer(container)),
|
|
5469
|
+
update: (params) => run(async () => {
|
|
5470
|
+
if (params.networkPolicy) await applyEgressPolicy(container, params.networkPolicy);
|
|
5471
|
+
}),
|
|
5472
|
+
writeFiles: (files) => run(() => writeFiles(id, files))
|
|
5473
|
+
};
|
|
5474
|
+
}
|
|
5475
|
+
// src/chat/sandbox/docker/provider.ts
|
|
5476
|
+
/** Create a sandbox container and return a session for it. */
|
|
5477
|
+
async function createSession(input) {
|
|
5478
|
+
const image = input.snapshotId ?? await ensureRuntimeImage(input.runtime ?? "node22", input.signal);
|
|
5479
|
+
const container = await createContainer({
|
|
5480
|
+
name: input.name ?? newSandboxName(),
|
|
5481
|
+
image,
|
|
5482
|
+
timeoutMs: input.timeoutMs,
|
|
5483
|
+
persistent: input.persistent ?? false,
|
|
5484
|
+
vcpus: input.resources?.vcpus,
|
|
5485
|
+
signal: input.signal
|
|
5486
|
+
}, await egressCaPem());
|
|
5487
|
+
try {
|
|
5488
|
+
if (input.networkPolicy) await applyEgressPolicy(container, input.networkPolicy);
|
|
5489
|
+
} catch (error) {
|
|
5490
|
+
await removeContainer(container.name).catch(() => void 0);
|
|
5491
|
+
throw error;
|
|
5492
|
+
}
|
|
5493
|
+
return adaptSession$1(container, input);
|
|
5494
|
+
}
|
|
5495
|
+
/** Return a session for a named sandbox container. Start it if it stopped. */
|
|
5496
|
+
async function getSession(input) {
|
|
5497
|
+
return adaptSession$1(await getContainer(input.name, await egressCaPem(), input.scopeSignal), input);
|
|
5498
|
+
}
|
|
5499
|
+
/** Create the persistent container that runs one Workspace snapshot build. */
|
|
5500
|
+
async function createWorkspaceSnapshotBuilder$1(input) {
|
|
5501
|
+
await createContainer({
|
|
5502
|
+
name: input.name,
|
|
5503
|
+
image: await ensureRuntimeImage(input.runtime, input.signal),
|
|
5504
|
+
timeoutMs: input.timeoutMs,
|
|
5505
|
+
persistent: true,
|
|
5506
|
+
vcpus: input.resources?.vcpus,
|
|
5507
|
+
signal: input.signal
|
|
5508
|
+
}, await egressCaPem());
|
|
5509
|
+
}
|
|
5510
|
+
/**
|
|
5511
|
+
* Return the container that runs one Workspace snapshot build.
|
|
5512
|
+
*
|
|
5513
|
+
* A Docker snapshot is an image that does not depend on its builder. So
|
|
5514
|
+
* `snapshot()` removes the builder right after the commit, and the image tag
|
|
5515
|
+
* keeps the builder's name. Deleting the builder later removes that tag.
|
|
5516
|
+
*/
|
|
5517
|
+
async function getWorkspaceSnapshotBuilder$1(name, signal) {
|
|
5518
|
+
const container = await getContainer(name, await egressCaPem(), signal);
|
|
5519
|
+
return {
|
|
5520
|
+
session: () => adaptSession$1(container),
|
|
5521
|
+
startDetachedCommand: (input) => startDetachedCommand(container.id, input),
|
|
5522
|
+
async getCommand(commandId) {
|
|
5523
|
+
return { wait: ({ signal: waitSignal }) => waitForDetachedCommand(container.id, commandId, waitSignal) };
|
|
5524
|
+
},
|
|
5525
|
+
async snapshot(options) {
|
|
5526
|
+
const snapshotId = await commitSnapshot(container.id, name, options?.signal);
|
|
5527
|
+
await removeContainer(name);
|
|
5528
|
+
return { snapshotId };
|
|
5529
|
+
}
|
|
5530
|
+
};
|
|
5531
|
+
}
|
|
5532
|
+
async function deleteSnapshotTag(name) {
|
|
5533
|
+
try {
|
|
5534
|
+
await dockerRequest({
|
|
5535
|
+
method: "DELETE",
|
|
5536
|
+
path: `/images/${encodeURIComponent(`${SNAPSHOT_REPOSITORY}:${name}`)}`,
|
|
5537
|
+
query: { force: true }
|
|
5538
|
+
});
|
|
5539
|
+
} catch (error) {
|
|
5540
|
+
if (!(error instanceof DockerApiError && error.statusCode === 404)) throw error;
|
|
5541
|
+
}
|
|
5542
|
+
}
|
|
5543
|
+
/**
|
|
5544
|
+
* Remove Workspace builders and the snapshot images tagged with their names.
|
|
5545
|
+
*
|
|
5546
|
+
* Shared snapshot code calls this when a Workspace snapshot is no longer
|
|
5547
|
+
* used. Each name is tried, and the first failure is thrown at the end.
|
|
5548
|
+
*/
|
|
5549
|
+
async function deleteWorkspaceSnapshotBuilders$1(names) {
|
|
5550
|
+
const failures = [];
|
|
5551
|
+
for (const name of new Set(names)) for (const remove of [removeContainer, deleteSnapshotTag]) try {
|
|
5552
|
+
await remove(name);
|
|
5553
|
+
} catch (error) {
|
|
5554
|
+
failures.push(error);
|
|
5555
|
+
}
|
|
5556
|
+
if (failures.length > 0) throw failures[0];
|
|
5557
|
+
}
|
|
5558
|
+
/** Local Docker implementation of the sandbox provider contract. */
|
|
5559
|
+
const dockerSandboxProvider = {
|
|
5560
|
+
errors: dockerSandboxErrors,
|
|
5561
|
+
createSession,
|
|
5562
|
+
getSession,
|
|
5563
|
+
createWorkspaceSnapshotBuilder: createWorkspaceSnapshotBuilder$1,
|
|
5564
|
+
getWorkspaceSnapshotBuilder: getWorkspaceSnapshotBuilder$1,
|
|
5565
|
+
deleteWorkspaceSnapshotBuilders: deleteWorkspaceSnapshotBuilders$1
|
|
5566
|
+
};
|
|
5567
|
+
// src/chat/sandbox/vercel/credentials.ts
|
|
5568
|
+
/**
|
|
5569
|
+
* Resolve explicit Vercel Sandbox credentials, or return undefined to let
|
|
5570
|
+
* the SDK read ambient Vercel auth from its own environment detection.
|
|
5571
|
+
*/
|
|
5572
|
+
function getVercelSandboxCredentials() {
|
|
5573
|
+
const token = toOptionalTrimmed(process.env.VERCEL_TOKEN);
|
|
5574
|
+
const teamId = toOptionalTrimmed(process.env.VERCEL_TEAM_ID);
|
|
5575
|
+
const projectId = toOptionalTrimmed(process.env.VERCEL_PROJECT_ID);
|
|
5576
|
+
if (token && teamId && projectId) return {
|
|
5577
|
+
token,
|
|
5578
|
+
teamId,
|
|
5579
|
+
projectId
|
|
5580
|
+
};
|
|
5581
|
+
}
|
|
5582
|
+
// src/chat/slack/post-warning.ts
|
|
5583
|
+
const codes = /* @__PURE__ */ new Set([
|
|
5584
|
+
"invalid_metadata_format",
|
|
5585
|
+
"invalid_metadata_schema",
|
|
5586
|
+
"metadata_too_large",
|
|
5587
|
+
"invalid_arguments",
|
|
5588
|
+
"invalid_blocks",
|
|
5589
|
+
"missing_scope",
|
|
5590
|
+
"missing_charset",
|
|
5591
|
+
"superfluous_charset",
|
|
5592
|
+
"message_truncated"
|
|
5593
|
+
]);
|
|
5594
|
+
const pathFields = /* @__PURE__ */ new Set([
|
|
5595
|
+
"metadata",
|
|
5596
|
+
"event_type",
|
|
5597
|
+
"event_payload",
|
|
5598
|
+
"entities",
|
|
5599
|
+
"entity_type",
|
|
5600
|
+
"entity_payload",
|
|
5601
|
+
"external_ref",
|
|
5602
|
+
"id",
|
|
5603
|
+
"type",
|
|
5604
|
+
"url",
|
|
5605
|
+
"attributes",
|
|
5606
|
+
"title",
|
|
5607
|
+
"text",
|
|
5608
|
+
"display_id",
|
|
5609
|
+
"display_type",
|
|
5610
|
+
"product_name",
|
|
5611
|
+
"fields",
|
|
5612
|
+
"status",
|
|
5613
|
+
"custom_fields",
|
|
5614
|
+
"key",
|
|
5615
|
+
"label",
|
|
5616
|
+
"value",
|
|
5617
|
+
"long",
|
|
5618
|
+
"format",
|
|
5619
|
+
"link",
|
|
5620
|
+
"display_order",
|
|
5621
|
+
"app_unfurl_url",
|
|
5622
|
+
"app_id",
|
|
5623
|
+
"created_by",
|
|
5624
|
+
"created_at",
|
|
5625
|
+
"updated_by",
|
|
5626
|
+
"updated_at",
|
|
5627
|
+
"full_size_preview",
|
|
5628
|
+
"preview_url",
|
|
5629
|
+
"mime_type"
|
|
5630
|
+
]);
|
|
5631
|
+
const diagnosticWords = new Set(`WARN ERROR Message message metadata was incorrectly formatted The the will be
|
|
5632
|
+
ignored as a result For for event entity refer to following errors error
|
|
5633
|
+
missing required field fields property properties additional unexpected unknown
|
|
5634
|
+
unsupported invalid expected received actual type types must should match matches
|
|
5635
|
+
matching does not is are has have of at in on and or an only allowed allow
|
|
5636
|
+
value values string number integer boolean object array null minimum maximum
|
|
5637
|
+
length min max items item empty nonempty too long short large small size limit
|
|
5638
|
+
exceeded exceeds less greater than equal valid format schema validation failed
|
|
5639
|
+
failure cannot contain contains found one any all none enum pattern definition
|
|
5640
|
+
defined constraint constraints satisfy satisfies with without provided specified
|
|
5641
|
+
unrecognized unrecognised recognized recognised extraneous disallowed duplicate
|
|
5642
|
+
unique malformed nested root exactly needs need requires requirement supported
|
|
5643
|
+
json-pointer input different schema_uri schema_id schemas additionalProperties
|
|
5644
|
+
minLength maxLength minItems maxItems anyOf oneOf allOf`.split(/\s+/));
|
|
5645
|
+
function summarizeDiagnostic(value) {
|
|
5646
|
+
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) => {
|
|
5647
|
+
if (token.startsWith("/")) return token.split("/").slice(0, 17).map((part, index) => index === 0 ? "" : pathFields.has(part) ? part : "*").join("/");
|
|
5648
|
+
return codes.has(token) || pathFields.has(token) || diagnosticWords.has(token) ? token : "[value]";
|
|
5649
|
+
}).replace(/\s+/g, " ").slice(0, 2e3);
|
|
5650
|
+
}
|
|
5651
|
+
/** Report Slack warnings once, on the request span, without retrying the post. */
|
|
5652
|
+
function captureSlackPostWarning(response, attributes) {
|
|
5653
|
+
const warnings = response.response_metadata?.warnings ?? [];
|
|
5654
|
+
const messages = response.response_metadata?.messages ?? [];
|
|
5655
|
+
const knownCodes = [...new Set(warnings.slice(0, 20).filter((code) => codes.has(code)))].sort();
|
|
5656
|
+
const diagnostics = {
|
|
5657
|
+
"app.slack.warning_count": warnings.length,
|
|
5658
|
+
"app.slack.response_message_count": messages.length,
|
|
5659
|
+
"app.slack.diagnostic_codes": knownCodes
|
|
5660
|
+
};
|
|
5661
|
+
setSpanAttributes(diagnostics);
|
|
5662
|
+
if (!warnings.length && !messages.length) return;
|
|
5663
|
+
captureMessage("Slack chat.postMessage returned warnings", {
|
|
5664
|
+
level: "warning",
|
|
5665
|
+
fingerprint: ["slack.chat.postMessage.warning", ...knownCodes.length ? knownCodes : ["unknown"]],
|
|
5666
|
+
tags: { "app.slack.method": "chat.postMessage" },
|
|
5667
|
+
extra: {
|
|
5668
|
+
...getLogContextAttributes(),
|
|
5669
|
+
...attributes,
|
|
5670
|
+
...diagnostics,
|
|
5671
|
+
"app.slack.response_diagnostics": {
|
|
5672
|
+
warnings: warnings.slice(0, 20).map(summarizeDiagnostic),
|
|
5673
|
+
messages: messages.slice(0, 20).map(summarizeDiagnostic)
|
|
5674
|
+
}
|
|
5675
|
+
}
|
|
5676
|
+
});
|
|
5677
|
+
}
|
|
5678
|
+
// src/chat/slack/work-object.ts
|
|
5679
|
+
const url = z.url({ protocol: /^https?$/ });
|
|
5680
|
+
const textField = z.strictObject({
|
|
5681
|
+
type: z.literal("string"),
|
|
5682
|
+
key: z.string().min(1),
|
|
5683
|
+
label: z.string().min(1),
|
|
5684
|
+
value: z.string(),
|
|
5685
|
+
long: z.boolean().optional(),
|
|
5686
|
+
format: z.literal("markdown").optional(),
|
|
5687
|
+
link: url.optional()
|
|
5688
|
+
}).refine((field) => !field.format || !field.link, { message: "Slack string fields cannot combine format and link" });
|
|
5689
|
+
const customField = z.discriminatedUnion("type", [textField, z.strictObject({
|
|
5690
|
+
type: z.literal("slack#/types/timestamp"),
|
|
5691
|
+
key: z.string().min(1),
|
|
5692
|
+
label: z.string().min(1),
|
|
5693
|
+
value: z.number().int(),
|
|
5694
|
+
link: url.optional()
|
|
5695
|
+
})]);
|
|
5696
|
+
const payload = z.strictObject({
|
|
5697
|
+
attributes: z.strictObject({
|
|
5698
|
+
title: z.strictObject({ text: z.string().min(1) }),
|
|
5699
|
+
display_id: z.string().optional(),
|
|
5700
|
+
display_type: z.string().optional(),
|
|
5701
|
+
product_name: z.string().optional(),
|
|
5702
|
+
product_icon: z.strictObject({
|
|
5703
|
+
url,
|
|
5704
|
+
alt_text: z.string().min(1)
|
|
5705
|
+
}).optional()
|
|
5706
|
+
}),
|
|
5707
|
+
custom_fields: z.array(customField).optional(),
|
|
5708
|
+
display_order: z.array(z.string()).optional()
|
|
5709
|
+
});
|
|
5710
|
+
/** Limit IDs to Junior's safe subset; Slack silently rejects JSON punctuation. */
|
|
5711
|
+
const slackExternalRefIdSchema = z.string().min(1).regex(/^[A-Za-z0-9_-]+$/, "Unsupported Slack external reference ID");
|
|
5712
|
+
const entity = z.strictObject({
|
|
5713
|
+
external_ref: z.strictObject({
|
|
5714
|
+
id: slackExternalRefIdSchema,
|
|
5715
|
+
type: z.string().min(1).optional()
|
|
5716
|
+
}),
|
|
5717
|
+
url
|
|
5718
|
+
});
|
|
5719
|
+
/** Validate notification and detail metadata before Slack can ignore bad fields. */
|
|
5720
|
+
const slackEntitySchema = z.discriminatedUnion("entity_type", [entity.extend({
|
|
5721
|
+
entity_type: z.literal("slack#/entities/item"),
|
|
5722
|
+
entity_payload: payload.extend({ fields: z.never().optional() })
|
|
5723
|
+
}), entity.extend({
|
|
5724
|
+
entity_type: z.literal("slack#/entities/task"),
|
|
5725
|
+
entity_payload: payload.extend({ fields: z.strictObject({ status: z.strictObject({ value: z.string() }).optional() }).optional() })
|
|
5726
|
+
})]);
|
|
5727
|
+
// src/chat/slack/outbound.ts
|
|
5728
|
+
const MAX_SLACK_MESSAGE_TEXT_CHARS = 4e4;
|
|
5729
|
+
function requireSlackConversationId(channelId, action) {
|
|
5730
|
+
const normalized = normalizeSlackConversationId(channelId);
|
|
5731
|
+
if (!normalized) throw new Error(`${action} requires a valid channel ID`);
|
|
5732
|
+
return normalized;
|
|
5733
|
+
}
|
|
5734
|
+
function requireSlackThreadTimestamp(threadTs, action) {
|
|
5735
|
+
const normalized = threadTs.trim();
|
|
5736
|
+
if (!normalized) throw new Error(`${action} requires a thread timestamp`);
|
|
5737
|
+
return normalized;
|
|
5738
|
+
}
|
|
5739
|
+
function requireSlackMessageTimestamp(timestamp, action) {
|
|
5740
|
+
const normalized = parseSlackMessageTs(timestamp);
|
|
5741
|
+
if (!normalized) throw new Error(`${action} requires a target message timestamp`);
|
|
5742
|
+
return normalized;
|
|
5743
|
+
}
|
|
5744
|
+
function requireSlackMessageText(text, action) {
|
|
5745
|
+
if (text.trim().length === 0) throw new Error(`${action} requires non-empty text`);
|
|
5746
|
+
if (text.length > MAX_SLACK_MESSAGE_TEXT_CHARS) throw new Error(`${action} text exceeds Slack's 40000 character truncation limit`);
|
|
5747
|
+
return text;
|
|
5748
|
+
}
|
|
5749
|
+
/** Resolve a Slack message permalink without making read-only workflows fail on lookup errors. */
|
|
5750
|
+
async function getSlackMessagePermalink(args) {
|
|
5751
|
+
try {
|
|
5752
|
+
return (await withSlackRetries(() => getSlackClient().chat.getPermalink({
|
|
5753
|
+
channel: args.channelId,
|
|
5754
|
+
message_ts: args.messageTs
|
|
5755
|
+
}), 3, {
|
|
5756
|
+
action: "chat.getPermalink",
|
|
5757
|
+
idempotent: true,
|
|
5758
|
+
spanAttributes: {
|
|
5759
|
+
"app.slack.channel_id": args.channelId,
|
|
5760
|
+
"app.slack.message_ts": args.messageTs
|
|
5761
|
+
}
|
|
5762
|
+
})).permalink;
|
|
5763
|
+
} catch {
|
|
5764
|
+
return;
|
|
5765
|
+
}
|
|
5766
|
+
}
|
|
5767
|
+
/** Open the bot's direct message with one Slack user and return its channel ID. */
|
|
5768
|
+
async function openSlackDirectMessage(userId) {
|
|
5769
|
+
const channelId = (await withSlackRetries(() => getSlackClient().conversations.open({ users: userId }), 3, { action: "conversations.open" })).channel?.id;
|
|
5770
|
+
if (!channelId) throw new Error("Slack did not return a direct message channel.");
|
|
5771
|
+
return channelId;
|
|
5772
|
+
}
|
|
5773
|
+
/** Post Slack `mrkdwn` text to a conversation or thread via the shared outbound boundary. */
|
|
5774
|
+
async function postSlackMessage(input) {
|
|
5775
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack message posting");
|
|
5776
|
+
const text = requireSlackMessageText(input.text, "Slack message posting");
|
|
5777
|
+
const entities = input.entities?.length ? slackEntitySchema.array().parse(input.entities) : void 0;
|
|
5778
|
+
const threadTs = input.threadTs ? requireSlackThreadTimestamp(input.threadTs, "Slack thread message posting") : void 0;
|
|
5779
|
+
const attributes = {
|
|
5780
|
+
"app.slack.channel_id": channelId,
|
|
5781
|
+
...threadTs ? { "app.slack.thread_ts": threadTs } : void 0,
|
|
5782
|
+
"app.slack.work_object.count": entities?.length ?? 0
|
|
5783
|
+
};
|
|
5784
|
+
const response = await withSlackRetries(async () => {
|
|
5785
|
+
const response = await getSlackClient().chat.postMessage({
|
|
5786
|
+
channel: channelId,
|
|
5787
|
+
text,
|
|
5788
|
+
unfurl_links: false,
|
|
5789
|
+
unfurl_media: false,
|
|
5790
|
+
...input.blocks?.length ? { blocks: input.blocks } : void 0,
|
|
5791
|
+
...entities ? { metadata: { entities } } : void 0,
|
|
5792
|
+
...threadTs ? { thread_ts: threadTs } : void 0
|
|
5793
|
+
});
|
|
5794
|
+
const messageId = parseSlackMessageTs(response.ts);
|
|
5795
|
+
setSpanAttributes({ "messaging.message.id": messageId });
|
|
5796
|
+
captureSlackPostWarning(response, {
|
|
5797
|
+
...attributes,
|
|
5798
|
+
"messaging.message.id": messageId
|
|
5799
|
+
});
|
|
5800
|
+
return response;
|
|
5801
|
+
}, 3, {
|
|
5802
|
+
action: "chat.postMessage",
|
|
5803
|
+
attributes
|
|
5804
|
+
});
|
|
5805
|
+
const messageTs = parseSlackMessageTs(response.ts);
|
|
5806
|
+
if (!messageTs) throw new Error("Slack message posted without ts");
|
|
5807
|
+
return {
|
|
5808
|
+
ts: messageTs,
|
|
5809
|
+
...input.includePermalink ? { permalink: await getSlackMessagePermalink({
|
|
5810
|
+
channelId,
|
|
5811
|
+
messageTs
|
|
5812
|
+
}) } : void 0
|
|
5813
|
+
};
|
|
5814
|
+
}
|
|
5815
|
+
/** Replace the text and blocks of a message that Junior posted. */
|
|
5816
|
+
async function updateSlackMessage(input) {
|
|
5817
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack message update");
|
|
5818
|
+
const timestamp = requireSlackMessageTimestamp(input.timestamp, "Slack message update");
|
|
5819
|
+
const text = requireSlackMessageText(input.text, "Slack message update");
|
|
5820
|
+
await withSlackRetries(() => getSlackClient().chat.update({
|
|
5821
|
+
blocks: input.blocks,
|
|
5822
|
+
channel: channelId,
|
|
5823
|
+
text,
|
|
5824
|
+
ts: timestamp
|
|
5825
|
+
}), 3, {
|
|
5826
|
+
action: "chat.update",
|
|
5827
|
+
idempotent: true,
|
|
5828
|
+
spanAttributes: {
|
|
5829
|
+
"app.slack.channel_id": channelId,
|
|
5830
|
+
"app.slack.message_ts": timestamp
|
|
5831
|
+
}
|
|
5832
|
+
});
|
|
5833
|
+
}
|
|
5834
|
+
/**
|
|
5835
|
+
* Open a modal for the person who clicked. A `trigger_id` works once and
|
|
5836
|
+
* only for three seconds, so this makes one attempt.
|
|
5837
|
+
* https://docs.slack.dev/reference/methods/views.open
|
|
5838
|
+
*/
|
|
5839
|
+
async function openSlackModal(input) {
|
|
5840
|
+
await withSlackRetries(() => getSlackClient().views.open({
|
|
5841
|
+
trigger_id: input.triggerId,
|
|
5842
|
+
view: input.view
|
|
5843
|
+
}), 1, { action: "views.open" });
|
|
5844
|
+
}
|
|
5845
|
+
/**
|
|
5846
|
+
* Post an ephemeral Slack message. Delivery is best-effort on Slack's side, but
|
|
5847
|
+
* request validation and Web API behavior are centralized here.
|
|
5848
|
+
*/
|
|
5849
|
+
async function postSlackEphemeralMessage(input) {
|
|
5850
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack ephemeral message posting");
|
|
5851
|
+
const userId = parseSlackUserId(input.userId);
|
|
5852
|
+
if (!userId) throw new Error("Slack ephemeral message posting requires a user ID");
|
|
5853
|
+
const text = requireSlackMessageText(input.text, "Slack ephemeral message posting");
|
|
5854
|
+
const threadTs = input.threadTs ? requireSlackThreadTimestamp(input.threadTs, "Slack ephemeral thread message posting") : void 0;
|
|
5855
|
+
return { messageTs: (await withSlackRetries(() => getSlackClient().chat.postEphemeral({
|
|
5856
|
+
channel: channelId,
|
|
5857
|
+
user: userId,
|
|
5858
|
+
text,
|
|
5859
|
+
...input.blocks?.length ? { blocks: input.blocks } : void 0,
|
|
5860
|
+
...threadTs ? { thread_ts: threadTs } : void 0
|
|
5861
|
+
}), 3, {
|
|
5862
|
+
action: "chat.postEphemeral",
|
|
5863
|
+
spanAttributes: {
|
|
5864
|
+
"app.slack.channel_id": channelId,
|
|
5865
|
+
"app.slack.user_id": userId,
|
|
5866
|
+
...threadTs ? { "app.slack.thread_ts": threadTs } : void 0
|
|
5867
|
+
}
|
|
5868
|
+
})).message_ts };
|
|
5869
|
+
}
|
|
5870
|
+
/** Upload files into a Slack conversation or thread via the shared outbound file boundary. */
|
|
5871
|
+
async function uploadFilesToConversation(input) {
|
|
5872
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack file upload");
|
|
5873
|
+
const threadTs = input.threadTs ? requireSlackThreadTimestamp(input.threadTs, "Slack file upload") : void 0;
|
|
5874
|
+
if (input.files.length === 0) throw new Error("Slack file upload requires at least one file");
|
|
5875
|
+
const fileUploads = input.files.map((file) => {
|
|
5876
|
+
const filename = file.filename.trim();
|
|
5877
|
+
if (!filename) throw new Error("Slack file upload requires every file to have a filename");
|
|
5878
|
+
return {
|
|
5879
|
+
file: file.data,
|
|
5880
|
+
filename
|
|
5881
|
+
};
|
|
5882
|
+
});
|
|
5883
|
+
return { files: ((await withSlackRetries(() => getSlackClient().filesUploadV2({
|
|
5884
|
+
channel_id: channelId,
|
|
5885
|
+
...threadTs ? { thread_ts: threadTs } : void 0,
|
|
5886
|
+
file_uploads: fileUploads
|
|
5887
|
+
}), 3, {
|
|
5888
|
+
action: "filesUploadV2",
|
|
5889
|
+
spanAttributes: {
|
|
5890
|
+
"app.slack.channel_id": channelId,
|
|
5891
|
+
...threadTs ? { "app.slack.thread_ts": threadTs } : void 0
|
|
5892
|
+
}
|
|
5893
|
+
})).files ?? []).flatMap((completion) => (completion.files ?? []).map((file) => file.id ? { id: file.id } : {})) };
|
|
5894
|
+
}
|
|
5895
|
+
/** Add a reaction to a Slack message, treating `already_reacted` as idempotent success. */
|
|
5896
|
+
async function addReactionToMessage(input) {
|
|
5897
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack reaction");
|
|
5898
|
+
const timestamp = requireSlackMessageTimestamp(input.timestamp, "Slack reaction");
|
|
5899
|
+
const emoji = normalizeSlackEmojiName(input.emoji);
|
|
5900
|
+
if (!emoji) throw new Error("Slack reaction requires a valid emoji alias name");
|
|
5901
|
+
try {
|
|
5902
|
+
await withSlackRetries(() => getSlackClient(input.timeoutMs === void 0 ? void 0 : { timeoutMs: input.timeoutMs }).reactions.add({
|
|
5903
|
+
channel: channelId,
|
|
5904
|
+
timestamp,
|
|
5905
|
+
name: emoji
|
|
5906
|
+
}), input.timeoutMs === void 0 ? 3 : 1, {
|
|
5907
|
+
action: "reactions.add",
|
|
5908
|
+
idempotent: true,
|
|
5909
|
+
spanAttributes: {
|
|
5910
|
+
"app.slack.channel_id": channelId,
|
|
5911
|
+
"app.slack.message_ts": timestamp,
|
|
5912
|
+
"app.slack.reaction": emoji
|
|
5913
|
+
}
|
|
5914
|
+
});
|
|
5915
|
+
} catch (error) {
|
|
5916
|
+
if (error instanceof SlackActionError && error.code === "already_reacted") return { ok: true };
|
|
5917
|
+
throw error;
|
|
5918
|
+
}
|
|
5919
|
+
return { ok: true };
|
|
5920
|
+
}
|
|
5921
|
+
/** Remove a reaction from a Slack message, treating `no_reaction` as idempotent success. */
|
|
5922
|
+
async function removeReactionFromMessage(input) {
|
|
5923
|
+
const channelId = requireSlackConversationId(input.channelId, "Slack reaction removal");
|
|
5924
|
+
const timestamp = requireSlackMessageTimestamp(input.timestamp, "Slack reaction removal");
|
|
5925
|
+
const emoji = normalizeSlackEmojiName(input.emoji);
|
|
5926
|
+
if (!emoji) throw new Error("Slack reaction removal requires a valid emoji alias name");
|
|
5927
|
+
try {
|
|
5928
|
+
await withSlackRetries(() => getSlackClient().reactions.remove({
|
|
5929
|
+
channel: channelId,
|
|
5930
|
+
timestamp,
|
|
5931
|
+
name: emoji
|
|
5932
|
+
}), 3, {
|
|
5933
|
+
action: "reactions.remove",
|
|
5934
|
+
idempotent: true,
|
|
5935
|
+
spanAttributes: {
|
|
5936
|
+
"app.slack.channel_id": channelId,
|
|
5937
|
+
"app.slack.message_ts": timestamp,
|
|
5938
|
+
"app.slack.reaction": emoji
|
|
5939
|
+
}
|
|
5940
|
+
});
|
|
5941
|
+
} catch (error) {
|
|
5942
|
+
if (error instanceof SlackActionError && error.code === "no_reaction") return { ok: true };
|
|
5943
|
+
throw error;
|
|
5944
|
+
}
|
|
5945
|
+
return { ok: true };
|
|
5946
|
+
}
|
|
5947
|
+
// src/chat/slack/status-format.ts
|
|
5948
|
+
const SLACK_STATUS_MAX_LENGTH = 50;
|
|
5949
|
+
/** Truncate a status string to fit Slack's 50-char assistant status limit. */
|
|
5950
|
+
function truncateStatusText(text) {
|
|
5951
|
+
const trimmed = text.trim();
|
|
5952
|
+
if (!trimmed) return "";
|
|
5953
|
+
if (trimmed.length <= SLACK_STATUS_MAX_LENGTH) return trimmed;
|
|
5954
|
+
return `${trimmed.slice(0, 47).trimEnd()}...`;
|
|
5955
|
+
}
|
|
5956
|
+
// src/chat/slack/mrkdwn.ts
|
|
5957
|
+
/** Escape dynamic text for Slack mrkdwn without changing intended formatting. */
|
|
5958
|
+
function escapeSlackMrkdwnText(text) {
|
|
5959
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
5960
|
+
}
|
|
5961
|
+
/** Escape a URL for Slack explicit link syntax while preserving query semantics. */
|
|
5962
|
+
function escapeSlackLinkUrl(url) {
|
|
5963
|
+
return url.replaceAll("&", "&").replaceAll("<", "%3C").replaceAll(">", "%3E");
|
|
5964
|
+
}
|
|
5965
|
+
/** Build a Slack explicit link from dynamic URL and label values. */
|
|
5966
|
+
function formatSlackLink(url, label) {
|
|
5967
|
+
return `<${escapeSlackLinkUrl(url)}|${escapeSlackMrkdwnText(label)}>`;
|
|
5968
|
+
}
|
|
5969
|
+
function readInlineCodeSpan(line, start) {
|
|
5970
|
+
if (line[start] !== "`") return;
|
|
5971
|
+
let n = 1;
|
|
5972
|
+
while (line[start + n] === "`") n++;
|
|
5973
|
+
const marker = "`".repeat(n);
|
|
5974
|
+
let search = start + n;
|
|
5975
|
+
while (search < line.length) {
|
|
5976
|
+
const close = line.indexOf(marker, search);
|
|
5977
|
+
if (close === -1) return;
|
|
5978
|
+
const after = close + n;
|
|
5979
|
+
if (line[after] !== "`") return {
|
|
5980
|
+
text: line.slice(start, after),
|
|
5981
|
+
end: after
|
|
5982
|
+
};
|
|
5983
|
+
search = after + 1;
|
|
5984
|
+
}
|
|
5985
|
+
}
|
|
5986
|
+
function readExistingSlackAngleToken(line, start) {
|
|
5987
|
+
if (line[start] !== "<") return;
|
|
5988
|
+
const close = line.indexOf(">", start + 1);
|
|
5989
|
+
if (close === -1) return;
|
|
5990
|
+
const body = line.slice(start + 1, close);
|
|
5991
|
+
if (/^(?:https?:\/\/|@|#|!)/.test(body)) return {
|
|
5992
|
+
text: line.slice(start, close + 1),
|
|
5993
|
+
end: close + 1
|
|
5994
|
+
};
|
|
5995
|
+
}
|
|
5996
|
+
function readMarkdownLink(line, start) {
|
|
5997
|
+
if (line[start] !== "[") return;
|
|
5998
|
+
const labelEnd = line.indexOf("](", start + 1);
|
|
5999
|
+
if (labelEnd === -1) return;
|
|
6000
|
+
const destStart = labelEnd + 2;
|
|
6001
|
+
if (!line.startsWith("http://", destStart) && !line.startsWith("https://", destStart)) return;
|
|
6002
|
+
const closeParens = line.indexOf(")", destStart);
|
|
6003
|
+
if (closeParens === -1) return;
|
|
6004
|
+
return {
|
|
6005
|
+
text: line.slice(start, closeParens + 1),
|
|
6006
|
+
end: closeParens + 1
|
|
6007
|
+
};
|
|
6008
|
+
}
|
|
6009
|
+
function hasUnmatchedClosingParen(text) {
|
|
6010
|
+
let balance = 0;
|
|
6011
|
+
for (const ch of text) if (ch === "(") balance++;
|
|
6012
|
+
else if (ch === ")") balance--;
|
|
6013
|
+
return balance < 0;
|
|
6014
|
+
}
|
|
6015
|
+
function readBareUrl(line, start) {
|
|
6016
|
+
let end = start;
|
|
6017
|
+
while (end < line.length) {
|
|
6018
|
+
const ch = line.charAt(end);
|
|
6019
|
+
if (/\s/.test(ch) || ch === "<" || ch === ">" || ch === "\"" || ch === "`" || ch === "|" || ch === "*") break;
|
|
6020
|
+
end++;
|
|
6021
|
+
}
|
|
6022
|
+
if (end === start) return;
|
|
6023
|
+
let raw = line.slice(start, end);
|
|
6024
|
+
let suffix = "";
|
|
6025
|
+
const peel = () => {
|
|
6026
|
+
suffix = raw.slice(-1) + suffix;
|
|
6027
|
+
raw = raw.slice(0, -1);
|
|
6028
|
+
};
|
|
6029
|
+
const shouldPeel = () => raw.endsWith("_") || /[.,!?;:]$/.test(raw) || raw.endsWith(")") && hasUnmatchedClosingParen(raw);
|
|
6030
|
+
while (raw.length > 0 && shouldPeel()) peel();
|
|
6031
|
+
if (!/^https?:\/\/.+/.test(raw)) return;
|
|
6032
|
+
return {
|
|
6033
|
+
url: raw,
|
|
6034
|
+
suffix,
|
|
6035
|
+
end
|
|
6036
|
+
};
|
|
6037
|
+
}
|
|
6038
|
+
const BARE_SLACK_USER_ID_RE = /^@([UW](?=[A-Z0-9]*\d)[A-Z0-9]{8,})(?![\w])/;
|
|
6039
|
+
function readBareSlackUserMention(line, start) {
|
|
6040
|
+
if (line[start] !== "@" || /\w/.test(line[start - 1] ?? "")) return;
|
|
6041
|
+
const match = BARE_SLACK_USER_ID_RE.exec(line.slice(start));
|
|
6042
|
+
if (!match) return;
|
|
6043
|
+
return {
|
|
6044
|
+
text: `<@${match[1]}>`,
|
|
6045
|
+
end: start + match[0].length
|
|
6046
|
+
};
|
|
6047
|
+
}
|
|
6048
|
+
/**
|
|
6049
|
+
* Wrap bare http(s) URLs and bare `@U123` Slack user IDs on a single line as
|
|
6050
|
+
* Slack explicit `<url>` links and `<@U123>` mentions.
|
|
6051
|
+
*/
|
|
6052
|
+
function wrapBareReferencesOnLine(line) {
|
|
6053
|
+
let result = "";
|
|
6054
|
+
let i = 0;
|
|
6055
|
+
while (i < line.length) {
|
|
6056
|
+
const codeSpan = readInlineCodeSpan(line, i);
|
|
6057
|
+
if (codeSpan) {
|
|
6058
|
+
result += codeSpan.text;
|
|
6059
|
+
i = codeSpan.end;
|
|
6060
|
+
continue;
|
|
6061
|
+
}
|
|
6062
|
+
const angleToken = readExistingSlackAngleToken(line, i);
|
|
6063
|
+
if (angleToken) {
|
|
6064
|
+
result += angleToken.text;
|
|
6065
|
+
i = angleToken.end;
|
|
6066
|
+
continue;
|
|
6067
|
+
}
|
|
6068
|
+
const mdLink = readMarkdownLink(line, i);
|
|
6069
|
+
if (mdLink) {
|
|
6070
|
+
result += mdLink.text;
|
|
6071
|
+
i = mdLink.end;
|
|
6072
|
+
continue;
|
|
6073
|
+
}
|
|
6074
|
+
const userMention = readBareSlackUserMention(line, i);
|
|
6075
|
+
if (userMention) {
|
|
6076
|
+
result += userMention.text;
|
|
6077
|
+
i = userMention.end;
|
|
6078
|
+
continue;
|
|
6079
|
+
}
|
|
6080
|
+
if (line.startsWith("https://", i) || line.startsWith("http://", i)) {
|
|
6081
|
+
const parsed = readBareUrl(line, i);
|
|
6082
|
+
if (parsed) {
|
|
6083
|
+
result += `<${parsed.url}>${parsed.suffix}`;
|
|
6084
|
+
i = parsed.end;
|
|
6085
|
+
continue;
|
|
6086
|
+
}
|
|
6087
|
+
}
|
|
6088
|
+
result += line[i];
|
|
6089
|
+
i++;
|
|
6090
|
+
}
|
|
6091
|
+
return result;
|
|
6092
|
+
}
|
|
6093
|
+
/**
|
|
6094
|
+
* Pre-wrap bare http(s) URLs outside fenced code blocks as Slack explicit
|
|
6095
|
+
* links, preventing Slack's auto-linker from consuming adjacent formatting
|
|
6096
|
+
* markers into the URL. Bare `@U123` user IDs become `<@U123>` mentions,
|
|
6097
|
+
* because Slack shows a bare ID as literal text.
|
|
6098
|
+
*
|
|
6099
|
+
* Uses the same fence-toggle rule as `ensureBlockSpacing` so both passes
|
|
6100
|
+
* agree on which lines are code.
|
|
6101
|
+
*/
|
|
6102
|
+
function wrapBareReferences(text) {
|
|
6103
|
+
const lines = text.split("\n");
|
|
6104
|
+
const out = [];
|
|
6105
|
+
let inCodeBlock = false;
|
|
6106
|
+
for (const line of lines) {
|
|
6107
|
+
if (line.trimStart().startsWith("```")) {
|
|
6108
|
+
inCodeBlock = !inCodeBlock;
|
|
6109
|
+
out.push(line);
|
|
6110
|
+
continue;
|
|
6111
|
+
}
|
|
6112
|
+
out.push(inCodeBlock ? line : wrapBareReferencesOnLine(line));
|
|
6113
|
+
}
|
|
6114
|
+
return out.join("\n");
|
|
6115
|
+
}
|
|
6116
|
+
const LIST_ITEM_PATTERN = /^[-*•]\s|^\d+\.\s/;
|
|
6117
|
+
/**
|
|
6118
|
+
* Turn a follow-up paragraph inside a list item into a bullet at the same
|
|
6119
|
+
* indent.
|
|
6120
|
+
*
|
|
6121
|
+
* Slack's `markdown` block keeps only one paragraph per list item. It joins
|
|
6122
|
+
* the other paragraphs to the top-level item's text with no space and renders
|
|
6123
|
+
* nested lists after them. As a bullet, the paragraph keeps its place and
|
|
6124
|
+
* nesting.
|
|
6125
|
+
*/
|
|
6126
|
+
function bulletListFollowUpParagraphs(text) {
|
|
6127
|
+
const lines = text.split("\n");
|
|
6128
|
+
let inCodeBlock = false;
|
|
6129
|
+
let inList = false;
|
|
6130
|
+
for (const [i, line] of lines.entries()) {
|
|
6131
|
+
const trimmed = line.trimStart();
|
|
6132
|
+
const isFence = trimmed.startsWith("```");
|
|
6133
|
+
if (!inCodeBlock && trimmed && trimmed.length === line.length) inList = LIST_ITEM_PATTERN.test(line);
|
|
6134
|
+
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}`;
|
|
6135
|
+
if (isFence) inCodeBlock = !inCodeBlock;
|
|
6136
|
+
}
|
|
6137
|
+
return lines.join("\n");
|
|
6138
|
+
}
|
|
6139
|
+
/** Insert blank lines between content blocks so Slack renders them with visual separation. */
|
|
6140
|
+
function ensureBlockSpacing(text) {
|
|
6141
|
+
const codeBlockPattern = /^```/;
|
|
6142
|
+
const lines = text.split("\n");
|
|
6143
|
+
const result = [];
|
|
6144
|
+
let inCodeBlock = false;
|
|
6145
|
+
for (const line of lines) {
|
|
6146
|
+
if (codeBlockPattern.test(line.trimStart())) {
|
|
6147
|
+
if (!inCodeBlock) {
|
|
6148
|
+
const prev = result.length > 0 ? result[result.length - 1] : void 0;
|
|
6149
|
+
if (prev !== void 0 && prev.trim() !== "") result.push("");
|
|
6150
|
+
}
|
|
6151
|
+
inCodeBlock = !inCodeBlock;
|
|
6152
|
+
result.push(line);
|
|
6153
|
+
continue;
|
|
6154
|
+
}
|
|
6155
|
+
if (inCodeBlock) {
|
|
6156
|
+
result.push(line);
|
|
6157
|
+
continue;
|
|
6158
|
+
}
|
|
6159
|
+
const prev = result.length > 0 ? result[result.length - 1] : void 0;
|
|
6160
|
+
const isListItem = LIST_ITEM_PATTERN.test(line.trimStart());
|
|
6161
|
+
const isListContinuation = prev !== void 0 && !isListItem && /^\s/.test(line) && (/^\s/.test(prev) || LIST_ITEM_PATTERN.test(prev));
|
|
6162
|
+
if (prev !== void 0 && prev.trim() !== "" && line.trim() !== "" && !isListContinuation && !(LIST_ITEM_PATTERN.test(prev.trimStart()) && isListItem)) result.push("");
|
|
6163
|
+
result.push(line);
|
|
6164
|
+
}
|
|
6165
|
+
return result.join("\n");
|
|
6166
|
+
}
|
|
6167
|
+
/**
|
|
6168
|
+
* Normalize model-authored Slack markdown for delivery via `markdown_text`
|
|
6169
|
+
* or `{ type: "markdown" }` blocks.
|
|
6170
|
+
*
|
|
6171
|
+
* Plugins may first rewrite ordinary Markdown. Core then applies Slack-only
|
|
6172
|
+
* delivery shaping: bare URL wrapping, block spacing, and list follow-up
|
|
6173
|
+
* paragraph bullets. Slack reply delivery owns chunking and continuation
|
|
6174
|
+
* markers separately.
|
|
6175
|
+
*/
|
|
6176
|
+
function normalizeSlackReplyMarkdown(text) {
|
|
6177
|
+
let normalized = applyPluginFormatMarkdown(text).replace(/\r\n?/g, "\n").replace(/[ \t]+$/gm, "");
|
|
6178
|
+
normalized = wrapBareReferences(normalized);
|
|
6179
|
+
normalized = ensureBlockSpacing(normalized);
|
|
6180
|
+
normalized = bulletListFollowUpParagraphs(normalized);
|
|
6181
|
+
return normalized.replace(/\n{3,}/g, "\n\n").trim();
|
|
6182
|
+
}
|
|
6183
|
+
/** Normalize assistant status text before handing it to Slack. */
|
|
6184
|
+
function normalizeSlackStatusText(text) {
|
|
6185
|
+
const trimmed = text.trim();
|
|
6186
|
+
if (!trimmed) return "";
|
|
6187
|
+
return truncateStatusText(trimmed.replace(/(?:\.\s*)+$/, "").trim());
|
|
6188
|
+
}
|
|
6189
|
+
/** Render a Slack user mention, which Slack shows as the person's name. */
|
|
6190
|
+
function slackMention(userId) {
|
|
6191
|
+
return `<@${userId}>`;
|
|
6192
|
+
}
|
|
6193
|
+
// src/chat/slack/oauth-authorization-message.ts
|
|
6194
|
+
const ACCOUNT_SCOPE_NOTE = "This connects only your account. I can see only what you can see.";
|
|
6195
|
+
/** Present private OAuth authorization with a clear action and text fallback. */
|
|
6196
|
+
function buildSlackOAuthAuthorizationMessage(args) {
|
|
6197
|
+
return {
|
|
6198
|
+
text: `${formatSlackLink(args.authorizationUrl, args.label)}. ${ACCOUNT_SCOPE_NOTE} ${args.completionText}`,
|
|
6199
|
+
blocks: [{
|
|
6200
|
+
type: "section",
|
|
6201
|
+
text: {
|
|
6202
|
+
type: "mrkdwn",
|
|
6203
|
+
text: `*${escapeSlackMrkdwnText(args.label)}*\n${ACCOUNT_SCOPE_NOTE}\n${escapeSlackMrkdwnText(args.completionText)}`
|
|
6204
|
+
},
|
|
6205
|
+
accessory: {
|
|
6206
|
+
type: "button",
|
|
6207
|
+
text: {
|
|
6208
|
+
type: "plain_text",
|
|
6209
|
+
text: "Connect"
|
|
6210
|
+
},
|
|
6211
|
+
style: "primary",
|
|
6212
|
+
url: args.authorizationUrl,
|
|
6213
|
+
action_id: "oauth_connect"
|
|
6214
|
+
}
|
|
6215
|
+
}]
|
|
6216
|
+
};
|
|
6217
|
+
}
|
|
6218
|
+
// src/chat/oauth-flow.ts
|
|
6219
|
+
const OAUTH_STATE_TTL_MS = 6e5;
|
|
6220
|
+
function optionalString(value) {
|
|
6221
|
+
return typeof value === "string" ? value : void 0;
|
|
6222
|
+
}
|
|
6223
|
+
/** Parse OAuth callback state that was persisted before a provider redirect. */
|
|
6224
|
+
function parseOAuthStatePayload(value) {
|
|
6225
|
+
if (!isRecord$2(value)) return;
|
|
6226
|
+
if (typeof value.userId !== "string" || typeof value.provider !== "string") return;
|
|
6227
|
+
const actor = value.actor === void 0 ? void 0 : actorSchema.safeParse(value.actor);
|
|
6228
|
+
if (value.actor !== void 0 && (!actor || !actor.success)) return;
|
|
6229
|
+
const destination = parseDestination(value.destination);
|
|
6230
|
+
if (value.destination !== void 0 && !destination) return;
|
|
6231
|
+
const source = value.source === void 0 ? void 0 : sourceSchema.safeParse(value.source);
|
|
6232
|
+
if (value.source !== void 0 && (!source || !source.success)) return;
|
|
6233
|
+
return {
|
|
6234
|
+
userId: value.userId,
|
|
6235
|
+
provider: value.provider,
|
|
6236
|
+
...actor?.success ? { actor: actor.data } : void 0,
|
|
6237
|
+
...optionalString(value.channelId) ? { channelId: optionalString(value.channelId) } : void 0,
|
|
6238
|
+
...destination ? { destination } : void 0,
|
|
6239
|
+
...source?.success ? { source: source.data } : void 0,
|
|
6240
|
+
...optionalString(value.threadTs) ? { threadTs: optionalString(value.threadTs) } : void 0,
|
|
6241
|
+
...optionalString(value.resumeConversationId) ? { resumeConversationId: optionalString(value.resumeConversationId) } : void 0,
|
|
6242
|
+
...optionalString(value.resumeSessionId) ? { resumeSessionId: optionalString(value.resumeSessionId) } : void 0,
|
|
6243
|
+
...optionalString(value.scope) ? { scope: optionalString(value.scope) } : void 0
|
|
6244
|
+
};
|
|
6245
|
+
}
|
|
6246
|
+
/** Return the manifest-owned display label for a provider. */
|
|
6247
|
+
function formatProviderLabel(provider) {
|
|
6248
|
+
const displayName = pluginCatalogRuntime.getDisplayName(provider);
|
|
6249
|
+
if (!displayName) throw new Error(`Unknown plugin provider display name: "${provider}"`);
|
|
6250
|
+
return displayName;
|
|
6251
|
+
}
|
|
6252
|
+
/** Resolve the public base URL from environment variables (JUNIOR_BASE_URL or Vercel). */
|
|
6253
|
+
function resolveBaseUrl() {
|
|
6254
|
+
const explicit = process.env.JUNIOR_BASE_URL?.trim();
|
|
6255
|
+
if (explicit) return explicit;
|
|
6256
|
+
const vercelProd = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
|
|
6257
|
+
if (vercelProd) return `https://${vercelProd}`;
|
|
6258
|
+
const vercelUrl = process.env.VERCEL_URL?.trim();
|
|
6259
|
+
if (vercelUrl) return `https://${vercelUrl}`;
|
|
6260
|
+
}
|
|
6261
|
+
/**
|
|
6262
|
+
* Authorization links must only be visible to the requesting user.
|
|
6263
|
+
* Try in-context private delivery first, then fall back to a DM.
|
|
6264
|
+
*/
|
|
6265
|
+
async function deliverPrivateMessage(input) {
|
|
6266
|
+
let client;
|
|
6267
|
+
try {
|
|
6268
|
+
client = getSlackClient();
|
|
6269
|
+
} catch {
|
|
6270
|
+
logWarn("oauth.private_delivery.skipped", { "app.reason": "missing_bot_token" });
|
|
6271
|
+
return false;
|
|
6272
|
+
}
|
|
6273
|
+
if (input.channelId) try {
|
|
6274
|
+
if (isDmChannel(input.channelId)) await postSlackMessage({
|
|
6275
|
+
channelId: input.channelId,
|
|
6276
|
+
text: input.text,
|
|
6277
|
+
blocks: input.blocks,
|
|
6278
|
+
threadTs: input.threadTs
|
|
6279
|
+
});
|
|
6280
|
+
else await postSlackEphemeralMessage({
|
|
6281
|
+
channelId: input.channelId,
|
|
6282
|
+
userId: input.userId,
|
|
6283
|
+
text: input.text,
|
|
6284
|
+
blocks: input.blocks,
|
|
6285
|
+
threadTs: input.threadTs
|
|
6286
|
+
});
|
|
6287
|
+
return "in_context";
|
|
6288
|
+
} catch (error) {
|
|
6289
|
+
logWarn("oauth.private_delivery.failed", {
|
|
6290
|
+
"app.slack.error": error instanceof Error ? error.message : String(error),
|
|
6291
|
+
"app.slack.channel": input.channelId
|
|
6292
|
+
});
|
|
6293
|
+
}
|
|
6294
|
+
try {
|
|
6295
|
+
const dmChannelId = (await withSlackRetries(() => client.conversations.open({ users: input.userId }), 3, {
|
|
6296
|
+
action: "conversations.open",
|
|
6297
|
+
spanAttributes: { "app.slack.user_id": input.userId }
|
|
6298
|
+
})).channel?.id;
|
|
6299
|
+
if (!dmChannelId) {
|
|
6300
|
+
logWarn("oauth.dm.fallback.failed", { "app.reason": "no_dm_channel_id" });
|
|
6301
|
+
return false;
|
|
6302
|
+
}
|
|
6303
|
+
await postSlackMessage({
|
|
6304
|
+
channelId: dmChannelId,
|
|
6305
|
+
text: input.text,
|
|
6306
|
+
blocks: input.blocks
|
|
6307
|
+
});
|
|
6308
|
+
return "fallback_dm";
|
|
6309
|
+
} catch (error) {
|
|
6310
|
+
logWarn("oauth.dm.fallback.failed", { "app.slack.error": error instanceof Error ? error.message : String(error) });
|
|
6311
|
+
return false;
|
|
6312
|
+
}
|
|
6313
|
+
}
|
|
6314
|
+
/** Present one OAuth request through the active surface's private delivery. */
|
|
6315
|
+
async function deliverOAuthAuthorization(request, input) {
|
|
6316
|
+
if (input.authorization) {
|
|
6317
|
+
await input.authorization.deliver(request);
|
|
6318
|
+
return "in_context";
|
|
6319
|
+
}
|
|
6320
|
+
return await deliverPrivateMessage({
|
|
6321
|
+
channelId: input.channelId,
|
|
6322
|
+
threadTs: input.threadTs,
|
|
6323
|
+
userId: input.userId,
|
|
6324
|
+
...buildSlackOAuthAuthorizationMessage(request)
|
|
6325
|
+
});
|
|
6326
|
+
}
|
|
6327
|
+
/** Initiate an OAuth authorization code flow for a provider and deliver the auth link to the user. */
|
|
6328
|
+
async function startOAuthFlow(provider, input) {
|
|
6329
|
+
const providerConfig = pluginCatalogRuntime.getOAuthConfig(provider);
|
|
6330
|
+
if (!providerConfig) return {
|
|
6331
|
+
ok: false,
|
|
6332
|
+
error: `Provider "${provider}" does not support OAuth authorization`
|
|
6333
|
+
};
|
|
6334
|
+
const clientId = process.env[providerConfig.clientIdEnv]?.trim();
|
|
6335
|
+
if (!clientId) return {
|
|
6336
|
+
ok: false,
|
|
6337
|
+
error: `Missing ${providerConfig.clientIdEnv} environment variable`
|
|
6338
|
+
};
|
|
6339
|
+
const baseUrl = resolveBaseUrl();
|
|
6340
|
+
if (!baseUrl) return {
|
|
6341
|
+
ok: false,
|
|
6342
|
+
error: "Cannot determine base URL (set JUNIOR_BASE_URL or deploy to Vercel)"
|
|
6343
|
+
};
|
|
6344
|
+
const state = input.authorization ? await input.authorization.createState() : randomBytes(32).toString("hex");
|
|
6345
|
+
const requestedScope = input.scope ?? providerConfig.scope;
|
|
6346
|
+
await getStateAdapter().set(`oauth-state:${state}`, {
|
|
6347
|
+
userId: input.actorId,
|
|
6348
|
+
provider,
|
|
6349
|
+
...input.actor ? { actor: input.actor } : void 0,
|
|
6350
|
+
...input.channelId ? { channelId: input.channelId } : void 0,
|
|
6351
|
+
...input.destination ? { destination: input.destination } : void 0,
|
|
6352
|
+
...input.source ? { source: input.source } : void 0,
|
|
6353
|
+
...input.threadTs ? { threadTs: input.threadTs } : void 0,
|
|
6354
|
+
...input.resumeConversationId ? { resumeConversationId: input.resumeConversationId } : void 0,
|
|
6355
|
+
...input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : void 0,
|
|
6356
|
+
...requestedScope ? { scope: requestedScope } : void 0
|
|
6357
|
+
}, OAUTH_STATE_TTL_MS);
|
|
6358
|
+
const authorizeParams = new URLSearchParams({
|
|
6359
|
+
client_id: clientId,
|
|
6360
|
+
state,
|
|
6361
|
+
redirect_uri: `${baseUrl}${providerConfig.callbackPath}`,
|
|
6362
|
+
response_type: "code"
|
|
6363
|
+
});
|
|
6364
|
+
if (requestedScope) authorizeParams.set("scope", requestedScope);
|
|
6365
|
+
for (const [key, value] of Object.entries(providerConfig.authorizeParams ?? {})) authorizeParams.set(key, value);
|
|
6366
|
+
logInfo("jr_rpc.oauth.started", {
|
|
6367
|
+
"app.credential.provider": provider,
|
|
6368
|
+
...input.activeSkillName ? { "app.skill.name": input.activeSkillName } : void 0
|
|
6369
|
+
});
|
|
6370
|
+
return {
|
|
6371
|
+
ok: true,
|
|
6372
|
+
delivery: await deliverOAuthAuthorization({
|
|
6373
|
+
authorizationUrl: `${providerConfig.authorizeEndpoint}?${authorizeParams.toString()}`,
|
|
6374
|
+
label: `Connect your ${formatProviderLabel(provider)} account`,
|
|
6375
|
+
completionText: input.resumeSessionId ? "Once you've authorized, I'll continue automatically." : "Once you've authorized, you'll see a confirmation in Slack."
|
|
6376
|
+
}, {
|
|
6377
|
+
authorization: input.authorization,
|
|
6378
|
+
channelId: input.channelId,
|
|
6379
|
+
threadTs: input.threadTs,
|
|
6380
|
+
userId: input.actorId
|
|
6381
|
+
})
|
|
6382
|
+
};
|
|
6383
|
+
}
|
|
6384
|
+
// src/chat/sandbox/egress/oidc.ts
|
|
6385
|
+
const OIDC_DISCOVERY_CACHE_TTL_MS = 36e5;
|
|
6386
|
+
const OIDC_DISCOVERY_CACHE_MAX_ENTRIES = 8;
|
|
6387
|
+
const jwksByIssuer = /* @__PURE__ */ new Map();
|
|
6388
|
+
function buildDiscoveryUrl(issuer) {
|
|
6389
|
+
const url = new URL(issuer);
|
|
6390
|
+
if (url.protocol !== "https:" || url.hostname !== "oidc.vercel.com") throw new Error("Unexpected Vercel OIDC issuer");
|
|
6391
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
|
6392
|
+
url.search = "";
|
|
6393
|
+
url.hash = "";
|
|
6394
|
+
return url;
|
|
6395
|
+
}
|
|
6396
|
+
function buildJwksUrl(value) {
|
|
6397
|
+
const url = new URL(value);
|
|
6398
|
+
if (url.protocol !== "https:") throw new Error("Vercel OIDC discovery jwks_uri must use HTTPS");
|
|
6399
|
+
return url;
|
|
6400
|
+
}
|
|
6401
|
+
async function getJwks(issuer) {
|
|
6402
|
+
const now = Date.now();
|
|
6403
|
+
const cached = jwksByIssuer.get(issuer);
|
|
6404
|
+
if (cached && cached.expiresAtMs > now) return cached.jwks;
|
|
6405
|
+
if (cached) jwksByIssuer.delete(issuer);
|
|
6406
|
+
const response = await fetch(buildDiscoveryUrl(issuer), { redirect: "error" });
|
|
6407
|
+
if (!response.ok) throw new Error("Unable to load Vercel OIDC discovery metadata");
|
|
6408
|
+
const config = await response.json();
|
|
6409
|
+
if (!config.jwks_uri) throw new Error("Vercel OIDC discovery metadata did not include jwks_uri");
|
|
6410
|
+
const jwks = createRemoteJWKSet(buildJwksUrl(config.jwks_uri));
|
|
6411
|
+
if (!jwksByIssuer.has(issuer) && jwksByIssuer.size >= OIDC_DISCOVERY_CACHE_MAX_ENTRIES) {
|
|
6412
|
+
const oldestIssuer = jwksByIssuer.keys().next().value;
|
|
6413
|
+
if (oldestIssuer) jwksByIssuer.delete(oldestIssuer);
|
|
6414
|
+
}
|
|
6415
|
+
jwksByIssuer.set(issuer, {
|
|
6416
|
+
jwks,
|
|
6417
|
+
expiresAtMs: now + OIDC_DISCOVERY_CACHE_TTL_MS
|
|
6418
|
+
});
|
|
6419
|
+
return jwks;
|
|
4333
6420
|
}
|
|
4334
6421
|
/**
|
|
4335
|
-
*
|
|
6422
|
+
* Verify Vercel signed this Sandbox firewall proxy request for the active VM session.
|
|
4336
6423
|
*
|
|
4337
|
-
*
|
|
4338
|
-
*
|
|
4339
|
-
* verified VM id, and only then asks the credentialed egress engine to issue
|
|
4340
|
-
* credentials and contact the upstream provider.
|
|
6424
|
+
* `currentDate` is the time for the `exp` and `nbf` checks. It defaults to
|
|
6425
|
+
* now. Evals pass the real time because they replace `Date`.
|
|
4341
6426
|
*/
|
|
4342
|
-
async function
|
|
4343
|
-
const
|
|
4344
|
-
|
|
4345
|
-
const
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
host: upstreamUrl.hostname,
|
|
4351
|
-
method: request.method,
|
|
4352
|
-
path: upstreamUrl.pathname,
|
|
4353
|
-
status: 403
|
|
4354
|
-
}),
|
|
4355
|
-
...sandboxEgressRoutingAttributes(request, upstreamUrl)
|
|
4356
|
-
});
|
|
4357
|
-
return jsonError$1("No provider owns forwarded host", 403);
|
|
4358
|
-
}
|
|
4359
|
-
const credentialContext = parseSandboxEgressCredentialToken(input.credentialToken);
|
|
4360
|
-
if (!credentialContext || credentialContext.egressId !== activeEgressId) {
|
|
4361
|
-
logWarn("sandbox.egress.credential_context.unauthorized", {
|
|
4362
|
-
...sandboxEgressAttributes({
|
|
4363
|
-
egressId: activeEgressId,
|
|
4364
|
-
host: upstreamUrl.hostname,
|
|
4365
|
-
method: request.method,
|
|
4366
|
-
path: upstreamUrl.pathname,
|
|
4367
|
-
provider,
|
|
4368
|
-
status: 403
|
|
4369
|
-
}),
|
|
4370
|
-
...sandboxEgressRoutingAttributes(request, upstreamUrl)
|
|
4371
|
-
});
|
|
4372
|
-
return jsonError$1("Sandbox egress credential context is not authorized", 403);
|
|
4373
|
-
}
|
|
4374
|
-
return await continueSandboxEgressTrace(request, upstreamUrl.hostname, deps.tracePropagation ?? {}, credentialContext, async () => await executeCredentialedEgressRequest({
|
|
4375
|
-
activeEgressId,
|
|
4376
|
-
credentialContext,
|
|
4377
|
-
deps,
|
|
4378
|
-
provider,
|
|
4379
|
-
request,
|
|
4380
|
-
upstreamUrl
|
|
4381
|
-
}));
|
|
6427
|
+
async function verifyVercelSandboxOidcToken(token, options = {}) {
|
|
6428
|
+
const unverified = decodeJwt(token);
|
|
6429
|
+
if (typeof unverified.iss !== "string") throw new Error("Vercel OIDC token did not include an issuer");
|
|
6430
|
+
const jwks = await getJwks(unverified.iss);
|
|
6431
|
+
return (await jwtVerify(token, jwks, {
|
|
6432
|
+
issuer: unverified.iss,
|
|
6433
|
+
...options.currentDate ? { currentDate: options.currentDate } : void 0
|
|
6434
|
+
})).payload;
|
|
4382
6435
|
}
|
|
4383
6436
|
// src/chat/sandbox/egress/vercel.ts
|
|
4384
6437
|
const OIDC_TOKEN_HEADER = "vercel-sandbox-oidc-token";
|
|
@@ -4579,171 +6632,6 @@ async function proxyVercelSandboxEgressRequest(request, deps = {}) {
|
|
|
4579
6632
|
credentialToken: credentialTokenFromRequest(request)
|
|
4580
6633
|
}, deps);
|
|
4581
6634
|
}
|
|
4582
|
-
// src/chat/sandbox/http-error-details.ts
|
|
4583
|
-
const DEFAULT_PREVIEW_LIMIT = 512;
|
|
4584
|
-
function toTrimmedString(value, maxChars) {
|
|
4585
|
-
if (typeof value !== "string") return;
|
|
4586
|
-
const normalized = value.trim();
|
|
4587
|
-
if (!normalized) return;
|
|
4588
|
-
return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}…` : normalized;
|
|
4589
|
-
}
|
|
4590
|
-
/** Extract structured HTTP error metadata (status, headers, body preview) for logging. */
|
|
4591
|
-
function extractHttpErrorDetails(error, options = {}) {
|
|
4592
|
-
const prefix = options.attributePrefix ?? "app.http_error";
|
|
4593
|
-
const previewLimit = options.previewLimit ?? DEFAULT_PREVIEW_LIMIT;
|
|
4594
|
-
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
4595
|
-
const err = error ?? {};
|
|
4596
|
-
const attributes = {
|
|
4597
|
-
"error.type": normalizedError.name || "Error",
|
|
4598
|
-
"exception.message": toTrimmedString(normalizedError.message, previewLimit) ?? "HTTP error"
|
|
4599
|
-
};
|
|
4600
|
-
const response = err.response;
|
|
4601
|
-
const statusCode = typeof response?.status === "number" ? response.status : void 0;
|
|
4602
|
-
const statusText = toTrimmedString(response?.statusText, previewLimit);
|
|
4603
|
-
const responseUrl = toTrimmedString(response?.url, previewLimit);
|
|
4604
|
-
const responseText = toTrimmedString(err.text, previewLimit);
|
|
4605
|
-
const responseJson = toTrimmedString(err.json && typeof err.json === "object" ? JSON.stringify(err.json) : void 0, previewLimit);
|
|
4606
|
-
const contentType = toTrimmedString(response?.headers?.get?.("content-type"), previewLimit);
|
|
4607
|
-
const requestIdHeader = toTrimmedString(response?.headers?.get?.("x-request-id"), previewLimit);
|
|
4608
|
-
const vercelIdHeader = toTrimmedString(response?.headers?.get?.("x-vercel-id"), previewLimit);
|
|
4609
|
-
const requestId = requestIdHeader ?? vercelIdHeader;
|
|
4610
|
-
if (statusCode !== void 0) attributes["http.response.status_code"] = statusCode;
|
|
4611
|
-
if (statusText) attributes[`${prefix}.status_text`] = statusText;
|
|
4612
|
-
if (responseUrl) attributes["url.full"] = responseUrl;
|
|
4613
|
-
if (contentType) attributes["http.response.header.content-type"] = [contentType];
|
|
4614
|
-
if (requestIdHeader) attributes["http.response.header.x-request-id"] = [requestIdHeader];
|
|
4615
|
-
if (vercelIdHeader) attributes["http.response.header.x-vercel-id"] = [vercelIdHeader];
|
|
4616
|
-
if (responseText) {
|
|
4617
|
-
attributes[`${prefix}.response_text_preview`] = responseText;
|
|
4618
|
-
attributes[`${prefix}.response_text_length`] = responseText.length;
|
|
4619
|
-
}
|
|
4620
|
-
if (responseJson) attributes[`${prefix}.response_json_preview`] = responseJson;
|
|
4621
|
-
const summaryParts = [];
|
|
4622
|
-
if (statusCode !== void 0) summaryParts.push(`status=${statusCode}`);
|
|
4623
|
-
if (statusText) summaryParts.push(`statusText=${statusText}`);
|
|
4624
|
-
if (responseUrl) summaryParts.push(`url=${responseUrl}`);
|
|
4625
|
-
if (requestId) summaryParts.push(`requestId=${requestId}`);
|
|
4626
|
-
for (const field of options.extraFields ?? []) {
|
|
4627
|
-
const value = toTrimmedString(err[field.sourceKey], previewLimit);
|
|
4628
|
-
if (!value) continue;
|
|
4629
|
-
attributes[`${prefix}.${field.attributeKey}`] = value;
|
|
4630
|
-
summaryParts.push(`${field.summaryKey ?? field.attributeKey}=${value}`);
|
|
4631
|
-
}
|
|
4632
|
-
if (responseJson) summaryParts.push(`json=${responseJson}`);
|
|
4633
|
-
else if (responseText) summaryParts.push(`text=${responseText}`);
|
|
4634
|
-
const searchableText = `${normalizedError.message} ${responseText ?? ""} ${responseJson ?? ""}`.toLowerCase();
|
|
4635
|
-
return {
|
|
4636
|
-
attributes,
|
|
4637
|
-
summary: summaryParts.join(", "),
|
|
4638
|
-
searchableText
|
|
4639
|
-
};
|
|
4640
|
-
}
|
|
4641
|
-
// src/chat/sandbox/snapshot/not-ready-error.ts
|
|
4642
|
-
/** Report that a Workspace snapshot is not ready to use. */
|
|
4643
|
-
var WorkspaceSnapshotNotReadyError = class extends Error {
|
|
4644
|
-
workspaceName;
|
|
4645
|
-
constructor(workspaceName) {
|
|
4646
|
-
super(`Workspace ${workspaceName} snapshot is not ready`);
|
|
4647
|
-
this.name = "WorkspaceSnapshotNotReadyError";
|
|
4648
|
-
this.workspaceName = workspaceName;
|
|
4649
|
-
}
|
|
4650
|
-
};
|
|
4651
|
-
/** Return WorkspaceSnapshotNotReadyError from this error or its causes. */
|
|
4652
|
-
function getWorkspaceSnapshotNotReadyError(error) {
|
|
4653
|
-
const seen = /* @__PURE__ */ new Set();
|
|
4654
|
-
let current = error;
|
|
4655
|
-
while (current instanceof Error && !seen.has(current)) {
|
|
4656
|
-
if (current instanceof WorkspaceSnapshotNotReadyError) return current;
|
|
4657
|
-
seen.add(current);
|
|
4658
|
-
current = current.cause;
|
|
4659
|
-
}
|
|
4660
|
-
}
|
|
4661
|
-
/** True when this error or a cause is WorkspaceSnapshotNotReadyError. */
|
|
4662
|
-
function isWorkspaceSnapshotNotReadyError(error) {
|
|
4663
|
-
return getWorkspaceSnapshotNotReadyError(error) !== void 0;
|
|
4664
|
-
}
|
|
4665
|
-
/** User message when a Workspace sandbox is still preparing. */
|
|
4666
|
-
function workspaceSnapshotNotReadyUserMessage(error) {
|
|
4667
|
-
return `The ${error.workspaceName} workspace is still preparing its sandbox. Wait for that preparation to finish, then try again.`;
|
|
4668
|
-
}
|
|
4669
|
-
// src/chat/sandbox/errors.ts
|
|
4670
|
-
const SANDBOX_ERROR_FIELDS = [{
|
|
4671
|
-
sourceKey: "sandboxId",
|
|
4672
|
-
attributeKey: "sandbox_id",
|
|
4673
|
-
summaryKey: "sandboxId"
|
|
4674
|
-
}];
|
|
4675
|
-
/** Extract HTTP error details from a sandbox error for span data and checks. */
|
|
4676
|
-
function getSandboxErrorDetails(error) {
|
|
4677
|
-
return extractHttpErrorDetails(error, {
|
|
4678
|
-
attributePrefix: "app.sandbox.api_error",
|
|
4679
|
-
extraFields: [...SANDBOX_ERROR_FIELDS]
|
|
4680
|
-
});
|
|
4681
|
-
}
|
|
4682
|
-
/** Return true when any error in the `cause` chain matches the predicate. */
|
|
4683
|
-
function findInErrorChain(error, predicate) {
|
|
4684
|
-
const seen = /* @__PURE__ */ new Set();
|
|
4685
|
-
let current = error;
|
|
4686
|
-
while (current && !seen.has(current)) {
|
|
4687
|
-
if (predicate(current)) return true;
|
|
4688
|
-
seen.add(current);
|
|
4689
|
-
current = typeof current === "object" ? current.cause : void 0;
|
|
4690
|
-
}
|
|
4691
|
-
return false;
|
|
4692
|
-
}
|
|
4693
|
-
function getFirstErrorMessage(error) {
|
|
4694
|
-
const seen = /* @__PURE__ */ new Set();
|
|
4695
|
-
let current = error;
|
|
4696
|
-
while (current && !seen.has(current)) {
|
|
4697
|
-
if (current instanceof Error) {
|
|
4698
|
-
const message = current.message.trim();
|
|
4699
|
-
if (message) return message;
|
|
4700
|
-
}
|
|
4701
|
-
seen.add(current);
|
|
4702
|
-
current = typeof current === "object" ? current.cause : void 0;
|
|
4703
|
-
}
|
|
4704
|
-
}
|
|
4705
|
-
/** Detect sandbox filesystem operations that can safely ignore existing directories. */
|
|
4706
|
-
function isAlreadyExistsError(error) {
|
|
4707
|
-
const details = getSandboxErrorDetails(error);
|
|
4708
|
-
return details.searchableText.includes("already exists") || details.searchableText.includes("file exists") || details.searchableText.includes("eexist");
|
|
4709
|
-
}
|
|
4710
|
-
/** Detect cancellation so a durable builder stays available for check-in. */
|
|
4711
|
-
function isAbortError(error) {
|
|
4712
|
-
return findInErrorChain(error, (candidate) => {
|
|
4713
|
-
if (typeof DOMException !== "undefined" && candidate instanceof DOMException) return candidate.name === "AbortError";
|
|
4714
|
-
return candidate instanceof Error && candidate.name === "AbortError";
|
|
4715
|
-
});
|
|
4716
|
-
}
|
|
4717
|
-
/** Wrap setup failures. Keep WorkspaceSnapshotNotReadyError as-is. */
|
|
4718
|
-
function wrapSandboxSetupError(error) {
|
|
4719
|
-
const notReady = getWorkspaceSnapshotNotReadyError(error);
|
|
4720
|
-
if (notReady) return notReady;
|
|
4721
|
-
try {
|
|
4722
|
-
const details = getSandboxErrorDetails(error);
|
|
4723
|
-
if (details.summary) return new Error(`sandbox setup failed (${details.summary})`, { cause: error });
|
|
4724
|
-
} catch {}
|
|
4725
|
-
let causeMessage;
|
|
4726
|
-
try {
|
|
4727
|
-
causeMessage = getFirstErrorMessage(error);
|
|
4728
|
-
} catch (cause) {
|
|
4729
|
-
causeMessage = cause instanceof Error ? cause.message : void 0;
|
|
4730
|
-
}
|
|
4731
|
-
if (causeMessage && causeMessage.trim() && causeMessage !== "sandbox setup failed") {
|
|
4732
|
-
const oneLine = causeMessage.replace(/\s+/g, " ").trim();
|
|
4733
|
-
return new Error(`sandbox setup failed (${oneLine})`, { cause: error });
|
|
4734
|
-
}
|
|
4735
|
-
return new Error("sandbox setup failed", { cause: error });
|
|
4736
|
-
}
|
|
4737
|
-
/** Record span data and throw one stable sandbox operation error. */
|
|
4738
|
-
function throwSandboxOperationError(action, error, includeMissingPath = false) {
|
|
4739
|
-
const details = getSandboxErrorDetails(error);
|
|
4740
|
-
setSpanAttributes({
|
|
4741
|
-
...details.attributes,
|
|
4742
|
-
...includeMissingPath ? { "app.sandbox.api_error.missing_path": details.searchableText.includes("no such file") || details.searchableText.includes("enoent") } : void 0,
|
|
4743
|
-
"app.sandbox.success": false
|
|
4744
|
-
});
|
|
4745
|
-
throw new Error(details.summary ? `${action} failed (${details.summary})` : `${action} failed`, { cause: error });
|
|
4746
|
-
}
|
|
4747
6635
|
// src/chat/sandbox/vercel/errors.ts
|
|
4748
6636
|
function isRecord$1(value) {
|
|
4749
6637
|
return typeof value === "object" && value !== null;
|
|
@@ -4975,7 +6863,7 @@ const vercelSandboxProvider = {
|
|
|
4975
6863
|
// src/chat/sandbox/provider.ts
|
|
4976
6864
|
/** Return the sandbox provider for this deployment. */
|
|
4977
6865
|
function getSandboxProvider() {
|
|
4978
|
-
return vercelSandboxProvider;
|
|
6866
|
+
return getChatConfig().sandbox.provider === "docker" ? dockerSandboxProvider : vercelSandboxProvider;
|
|
4979
6867
|
}
|
|
4980
6868
|
// src/chat/sandbox/guest-docker.ts
|
|
4981
6869
|
/** Docker daemon config for Vercel sandbox guests (nested containers). */
|
|
@@ -5209,9 +7097,6 @@ function isStale(profile, createdAtMs) {
|
|
|
5209
7097
|
const maxAgeMs = floatingMaxAgeMs();
|
|
5210
7098
|
return maxAgeMs === 0 || Date.now() - createdAtMs > maxAgeMs;
|
|
5211
7099
|
}
|
|
5212
|
-
// src/chat/sandbox/snapshot/runtime.ts
|
|
5213
|
-
/** Runtime used to build and start reusable Sandbox snapshots. */
|
|
5214
|
-
const SANDBOX_RUNTIME = "node22";
|
|
5215
7100
|
// src/chat/sandbox/snapshot/store.ts
|
|
5216
7101
|
function builderNames(rows) {
|
|
5217
7102
|
return [...new Set(rows.map((row) => row.sandboxName).filter((name) => Boolean(name)))];
|
|
@@ -6202,6 +8087,7 @@ function getPluginRoutes(options) {
|
|
|
6202
8087
|
const identifiers = [...new Set(input.identifiers.map((identifier) => normalizeEventIdentifier(plugin.events, identifier)).filter(Boolean))];
|
|
6203
8088
|
if (identifiers.length === 0 || input.eventTypes.length === 0) return [];
|
|
6204
8089
|
return await options.events.neededMatchKeys({
|
|
8090
|
+
...input.data ? { data: eventDataSchema.parse(input.data) } : void 0,
|
|
6205
8091
|
eventTypes: input.eventTypes,
|
|
6206
8092
|
identifiers,
|
|
6207
8093
|
namespace: pluginName
|
|
@@ -6482,7 +8368,7 @@ function preparationSignal(inputSignal, ownerSignal) {
|
|
|
6482
8368
|
function createSandboxCapability(workspace, ownerSignal) {
|
|
6483
8369
|
return {
|
|
6484
8370
|
root: SANDBOX_WORKSPACE_ROOT,
|
|
6485
|
-
juniorRoot:
|
|
8371
|
+
juniorRoot: SANDBOX_JUNIOR_ROOT,
|
|
6486
8372
|
async readFile(filePath) {
|
|
6487
8373
|
return await workspace.readFileToBuffer({ path: filePath }) ?? null;
|
|
6488
8374
|
},
|
|
@@ -6616,6 +8502,7 @@ function createPluginHookRunner(input = {}) {
|
|
|
6616
8502
|
input: nextInput
|
|
6617
8503
|
},
|
|
6618
8504
|
users: { resolveActor: async () => input.actor ? await readActorIdentity(input.actor) : void 0 },
|
|
8505
|
+
juniorRoot: SANDBOX_JUNIOR_ROOT,
|
|
6619
8506
|
env: {
|
|
6620
8507
|
get(key) {
|
|
6621
8508
|
return env[key] ?? normalizeEnv(nextInput.env)[key];
|
|
@@ -7570,4 +9457,4 @@ async function executeCredentialedEgressRequest(input) {
|
|
|
7570
9457
|
});
|
|
7571
9458
|
}
|
|
7572
9459
|
}
|
|
7573
|
-
export {
|
|
9460
|
+
export { postSlackMessage as $, createPluginAnnotations as $n, downloadPrivateSlackFile as $t, setWorkspaceSnapshotBuild as A, createPluginState as An, slackMessageTurnDisabledReason as At, parseOAuthStatePayload as B, conversationNotArchivedForEmail as Bn, deliverWatch as Bt, listWorkspaces as C, SANDBOX_JUNIOR_ROOT as Cn, normalizeSandboxEgressTracePropagationDomains as Ct, invalidateReadySnapshot as D, sandboxSkillDir as Dn, resolveChannelCapabilities as Dt, WorkspaceValidationError as E, SANDBOX_WORKSPACE_ROOT as En, workspaceRepoCheckoutPath as Et, getSandboxProvider as F, conversationArchiveTimes as Fn, cancelSubscriptions as Ft, normalizeSlackReplyMarkdown as G, readConversationEventPage as Gn, bindEventAutomationCredentialSubject as Gt, startOAuthFlow as H, viewerConversationMembership as Hn, getWatch as Ht, isVercelSandboxEgressRequest as I, conversationArchivedForEmail as In, cancelWatch as It, truncateStatusText as J, matchSlackAttachments as Jn, verifyEventAutomationCredentialSubject as Jt, normalizeSlackStatusText as K, buildSentryConversationUrl as Kn, bindScheduledAutomationCredentialSubject as Kt, proxyVercelSandboxEgressRequest as L, conversationArchivedForUser as Ln, collectEventMatchKeys as Lt, hash as M, createPluginModel as Mn, RESOURCE_SUBSCRIPTION_MAX_TTL_MS as Mt, isStale as N, createPluginLogger as Nn, RESOURCE_WATCH_TOOL_SOURCE as Nt, loadSnapshotsForProfile as O, sandboxSkillFile as On, getSlackToolContext as Ot, ensureDockerDaemon as P, readConversationAccessFromSql as Pn, STOP_WATCHING_TOOL_NAME as Pt, openSlackModal as Q, saveObjectAnnotations as Qn, SlackActionError as Qt, deliverOAuthAuthorization as R, conversationHasParticipantUser as Rn, createTimerWatch as Rt, getWorkspaceByName as S, SANDBOX_DATA_ROOT as Sn, resolveSandboxEgressProviderForHost as St, updateWorkspace as T, SANDBOX_SKILLS_ROOT as Tn, tryWorkspaceRepoCheckoutPath as Tt, escapeSlackMrkdwnText as U, createPluginConversationEvents as Un, listWatches as Ut, resolveBaseUrl as V, conversationNotArchivedForUser as Vn, findMatchingWatches as Vt, formatSlackLink as W, readLatestConversationBrief as Wn, takeDueTimerWatches as Wt, getSlackMessagePermalink as X, recordSlackAttachment as Xn, verifySlackDirectCredentialSubject as Xt, addReactionToMessage as Y, readAttachmentVisionSummary as Yn, verifyScheduledAutomationCredentialSubject as Yt, openSlackDirectMessage as Z, resolveRootVisibility as Zn, verifyTimerWatchCredentialSubject as Zt, registration_exports as _, PluginToolDeniedError as _n, parseSandboxEgressCredentialToken as _t, agent_hooks_exports as a, normalizeSlackConversationId as an, createPluginCache as ar, isAbortError as at, deleteWorkspace as b, runNonInteractiveCommand as bn, buildSandboxEgressNetworkPolicy as bt, getPluginRoutes as c, sleep$1 as cn, agentsInstructionsUpdatedEvent as cr, wrapSandboxSetupError as ct, getPluginTools as d, JUNIOR_THREAD_STATE_TTL_MS as dn, conversationNoteEvent as dr, isWorkspaceSnapshotNotReadyError as dt, getFilePermalink as en, listConversationAnnotations as er, removeReactionFromMessage as et, getPluginUserPromptContributions as f, getDashboardConversationLink as fn, workspaceSnapshotNotReadyUserMessage as ft, coreTaskRegistrations as g, ToolInputError as gn, createSandboxEgressCredentialToken as gt, validatePlugins as h, setDashboardConversationLinkOptions as hn, consumeSandboxEgressPermissionDeniedSignal as ht, resolvePluginOAuthAccount as i, isDmChannel as in, lookupSlackUser as ir, slackExternalRefIdSchema as it, create as j, createPluginEmbedder as jn, RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS as jt, setWorkspaceSnapshot as k, sandboxSkillPathResolution as kn, isSlackMessageTurn as kt, getPluginSlackConversationLink as l, getWorkspaceTeamId as ln, authenticationLinkedEvent as lr, WorkspaceSnapshotNotReadyError as lt, setPlugins as m, getDashboardTaskLink as mn, consumeSandboxEgressAuthRequiredSignal as mt, createUserTokenStore as n, getSlackClient as nn, defaultTimezone as nr, uploadFilesToConversation as nt, createPluginHookRunner as o, runWithSlackInstallationToken as on, createCache as or, isAlreadyExistsError as ot, getPlugins as p, getDashboardObjectIconLink as pn, clearSandboxEgressSignals as pt, slackMention as q, getSlackFileId as qn, bindSlackDirectCredentialSubject as qt, getOAuthAccountProviders as r, isConversationScopedChannel as rn, resolveUserTimezone as rr, slackEntitySchema as rt, getPluginApiRoutes as s, withSlackRetries as sn, JUNIOR_NATIVE_EVENT_NAMESPACE as sr, throwSandboxOperationError as st, executeCredentialedEgressRequest as t, getHeaderString as tn, listConversationAnnotationsById as tr, updateSlackMessage as tt, getPluginSystemPromptContributions as u, runWithWorkspaceTeamId as un, authenticationUnlinkedEvent as ur, getWorkspaceSnapshotNotReadyError as ut, setBriefsConfig as v, buildCommandScript as vn, parseSandboxEgressAuthRequiredSignal as vt, listWorkspacesByRepository as w, SANDBOX_REPOS_ROOT as wn, SANDBOX_RUNTIME as wt, getWorkspace as x, SANDBOX_COMMAND_PATH as xn, resolveSandboxCommandEnvironment as xt, createWorkspace as y, buildNonInteractiveShellScript as yn, sandboxEgressSignalsResponseSchema as yt, formatProviderLabel as z, conversationIsMemberRoot as zn, createWatch as zt };
|