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