hyperbrane 0.1.0 → 0.1.2

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 (150) hide show
  1. package/README.md +41 -3
  2. package/dist/agent/agentService.d.ts +11 -3
  3. package/dist/agent/agentService.js +63 -19
  4. package/dist/agent/agentStorage.d.ts +2 -0
  5. package/dist/agent/agentStorage.js +4 -2
  6. package/dist/agent/api.d.ts +83 -32
  7. package/dist/agent/api.js +7 -0
  8. package/dist/agent/client.d.ts +3 -0
  9. package/dist/agent/client.js +5 -0
  10. package/dist/agent/hostTranscript.d.ts +13 -0
  11. package/dist/agent/hostTranscript.js +190 -0
  12. package/dist/agent/session/backend.d.ts +82 -0
  13. package/dist/agent/session/backend.js +1 -0
  14. package/dist/agent/session/claude/home.d.ts +2 -0
  15. package/dist/agent/session/claude/home.js +4 -0
  16. package/dist/agent/session/claude/transcript.d.ts +13 -0
  17. package/dist/agent/session/claude/transcript.js +29 -0
  18. package/dist/agent/session/client.d.ts +5 -1
  19. package/dist/agent/session/codex/home.d.ts +2 -0
  20. package/dist/agent/session/codex/home.js +4 -0
  21. package/dist/agent/session/codex/messages.d.ts +30 -0
  22. package/dist/agent/session/codex/messages.js +135 -0
  23. package/dist/agent/session/codex/transcript.d.ts +12 -0
  24. package/dist/agent/session/codex/transcript.js +22 -0
  25. package/dist/agent/session/fileTranscript.d.ts +92 -0
  26. package/dist/agent/session/fileTranscript.js +454 -0
  27. package/dist/agent/session/sdkMessages.d.ts +15 -0
  28. package/dist/agent/session/sdkMessages.js +28 -0
  29. package/dist/agent/session/sessionStorage.d.ts +4 -0
  30. package/dist/agent/session/sessionStorage.js +13 -2
  31. package/dist/agent/session/sharedConnection.d.ts +1 -0
  32. package/dist/agent/session/storedTranscripts.d.ts +13 -0
  33. package/dist/agent/session/storedTranscripts.js +8 -0
  34. package/dist/agent/session/worker/protocol.d.ts +5 -1
  35. package/dist/agent/session/worker/protocol.js +6 -1
  36. package/dist/agent/session/worker/workerParams.d.ts +11 -0
  37. package/dist/agent/session/worker/workerParams.js +163 -0
  38. package/dist/agent/session/workerSseConnection.d.ts +20 -4
  39. package/dist/agent/session/workerSseConnection.js +57 -7
  40. package/dist/agent/vendor-types/codex/app-server/AbsolutePathBuf.d.ts +10 -0
  41. package/dist/agent/vendor-types/codex/app-server/AbsolutePathBuf.js +2 -0
  42. package/dist/agent/vendor-types/codex/app-server/FunctionCallOutputBody.d.ts +2 -0
  43. package/dist/agent/vendor-types/codex/app-server/FunctionCallOutputBody.js +2 -0
  44. package/dist/agent/vendor-types/codex/app-server/FunctionCallOutputContentItem.d.ts +22 -0
  45. package/dist/agent/vendor-types/codex/app-server/FunctionCallOutputContentItem.js +2 -0
  46. package/dist/agent/vendor-types/codex/app-server/ImageDetail.d.ts +1 -0
  47. package/dist/agent/vendor-types/codex/app-server/ImageDetail.js +2 -0
  48. package/dist/agent/vendor-types/codex/app-server/ImageGenerationFailure.d.ts +5 -0
  49. package/dist/agent/vendor-types/codex/app-server/ImageGenerationFailure.js +2 -0
  50. package/dist/agent/vendor-types/codex/app-server/ImageGenerationItem.d.ts +11 -0
  51. package/dist/agent/vendor-types/codex/app-server/ImageGenerationItem.js +2 -0
  52. package/dist/agent/vendor-types/codex/app-server/LegacyAppPathString.d.ts +23 -0
  53. package/dist/agent/vendor-types/codex/app-server/LegacyAppPathString.js +2 -0
  54. package/dist/agent/vendor-types/codex/app-server/MessagePhase.d.ts +7 -0
  55. package/dist/agent/vendor-types/codex/app-server/MessagePhase.js +2 -0
  56. package/dist/agent/vendor-types/codex/app-server/ReasoningEffort.d.ts +4 -0
  57. package/dist/agent/vendor-types/codex/app-server/ReasoningEffort.js +2 -0
  58. package/dist/agent/vendor-types/codex/app-server/SleepItem.d.ts +7 -0
  59. package/dist/agent/vendor-types/codex/app-server/SleepItem.js +2 -0
  60. package/dist/agent/vendor-types/codex/app-server/WebSearchItem.d.ts +14 -0
  61. package/dist/agent/vendor-types/codex/app-server/WebSearchItem.js +2 -0
  62. package/dist/agent/vendor-types/codex/app-server/serde_json/JsonValue.d.ts +3 -0
  63. package/dist/agent/vendor-types/codex/app-server/serde_json/JsonValue.js +2 -0
  64. package/dist/agent/vendor-types/codex/app-server/v2/AgentMessageDelivery.d.ts +1 -0
  65. package/dist/agent/vendor-types/codex/app-server/v2/AgentMessageDelivery.js +2 -0
  66. package/dist/agent/vendor-types/codex/app-server/v2/AsyncUserInputQuestion.d.ts +4 -0
  67. package/dist/agent/vendor-types/codex/app-server/v2/AsyncUserInputQuestion.js +2 -0
  68. package/dist/agent/vendor-types/codex/app-server/v2/ByteRange.d.ts +4 -0
  69. package/dist/agent/vendor-types/codex/app-server/v2/ByteRange.js +2 -0
  70. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentState.d.ts +5 -0
  71. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentState.js +2 -0
  72. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentStatus.d.ts +1 -0
  73. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentStatus.js +2 -0
  74. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentTool.d.ts +1 -0
  75. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentTool.js +2 -0
  76. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentToolCallStatus.d.ts +1 -0
  77. package/dist/agent/vendor-types/codex/app-server/v2/CollabAgentToolCallStatus.js +2 -0
  78. package/dist/agent/vendor-types/codex/app-server/v2/CommandAction.d.ts +19 -0
  79. package/dist/agent/vendor-types/codex/app-server/v2/CommandAction.js +2 -0
  80. package/dist/agent/vendor-types/codex/app-server/v2/CommandExecutionSource.d.ts +1 -0
  81. package/dist/agent/vendor-types/codex/app-server/v2/CommandExecutionSource.js +2 -0
  82. package/dist/agent/vendor-types/codex/app-server/v2/CommandExecutionStatus.d.ts +1 -0
  83. package/dist/agent/vendor-types/codex/app-server/v2/CommandExecutionStatus.js +2 -0
  84. package/dist/agent/vendor-types/codex/app-server/v2/DynamicToolCallOutputContentItem.d.ts +10 -0
  85. package/dist/agent/vendor-types/codex/app-server/v2/DynamicToolCallOutputContentItem.js +2 -0
  86. package/dist/agent/vendor-types/codex/app-server/v2/DynamicToolCallStatus.d.ts +1 -0
  87. package/dist/agent/vendor-types/codex/app-server/v2/DynamicToolCallStatus.js +2 -0
  88. package/dist/agent/vendor-types/codex/app-server/v2/FileUpdateChange.d.ts +6 -0
  89. package/dist/agent/vendor-types/codex/app-server/v2/FileUpdateChange.js +2 -0
  90. package/dist/agent/vendor-types/codex/app-server/v2/HookPromptFragment.d.ts +4 -0
  91. package/dist/agent/vendor-types/codex/app-server/v2/HookPromptFragment.js +2 -0
  92. package/dist/agent/vendor-types/codex/app-server/v2/McpAppDisplayMode.d.ts +1 -0
  93. package/dist/agent/vendor-types/codex/app-server/v2/McpAppDisplayMode.js +2 -0
  94. package/dist/agent/vendor-types/codex/app-server/v2/McpAppUi.d.ts +8 -0
  95. package/dist/agent/vendor-types/codex/app-server/v2/McpAppUi.js +2 -0
  96. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallAppContext.d.ts +7 -0
  97. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallAppContext.js +2 -0
  98. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallError.d.ts +3 -0
  99. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallError.js +2 -0
  100. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallResult.d.ts +6 -0
  101. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallResult.js +2 -0
  102. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallStatus.d.ts +1 -0
  103. package/dist/agent/vendor-types/codex/app-server/v2/McpToolCallStatus.js +2 -0
  104. package/dist/agent/vendor-types/codex/app-server/v2/MemoryCitation.d.ts +5 -0
  105. package/dist/agent/vendor-types/codex/app-server/v2/MemoryCitation.js +2 -0
  106. package/dist/agent/vendor-types/codex/app-server/v2/MemoryCitationEntry.d.ts +6 -0
  107. package/dist/agent/vendor-types/codex/app-server/v2/MemoryCitationEntry.js +2 -0
  108. package/dist/agent/vendor-types/codex/app-server/v2/PatchApplyStatus.d.ts +1 -0
  109. package/dist/agent/vendor-types/codex/app-server/v2/PatchApplyStatus.js +2 -0
  110. package/dist/agent/vendor-types/codex/app-server/v2/PatchChangeKind.d.ts +8 -0
  111. package/dist/agent/vendor-types/codex/app-server/v2/PatchChangeKind.js +2 -0
  112. package/dist/agent/vendor-types/codex/app-server/v2/SubAgentActivityKind.d.ts +1 -0
  113. package/dist/agent/vendor-types/codex/app-server/v2/SubAgentActivityKind.js +2 -0
  114. package/dist/agent/vendor-types/codex/app-server/v2/TextElement.d.ts +11 -0
  115. package/dist/agent/vendor-types/codex/app-server/v2/TextElement.js +2 -0
  116. package/dist/agent/vendor-types/codex/app-server/v2/ThreadItem.d.ts +215 -0
  117. package/dist/agent/vendor-types/codex/app-server/v2/ThreadItem.js +2 -0
  118. package/dist/agent/vendor-types/codex/app-server/v2/UserInput.d.ts +35 -0
  119. package/dist/agent/vendor-types/codex/app-server/v2/UserInput.js +2 -0
  120. package/dist/agent/vendor-types/codex/app-server/v2/WebSearchAction.d.ts +14 -0
  121. package/dist/agent/vendor-types/codex/app-server/v2/WebSearchAction.js +2 -0
  122. package/dist/agent/workerBundle.generated.js +1 -1
  123. package/dist/cli/agent.js +30 -4
  124. package/dist/cli/services.d.ts +6 -5
  125. package/dist/cli/services.js +4 -3
  126. package/dist/dimension/api.d.ts +5 -4
  127. package/dist/dimension/paths.d.ts +4 -0
  128. package/dist/dimension/paths.js +10 -2
  129. package/dist/dimension/service.d.ts +5 -1
  130. package/dist/dimension/service.js +5 -1
  131. package/dist/dimension/storage.js +3 -2
  132. package/dist/index.d.ts +2 -3
  133. package/dist/index.js +7 -5
  134. package/dist/lib/docker.d.ts +1 -0
  135. package/dist/portal-gateway/api.d.ts +6 -5
  136. package/dist/portal-gateway/gatewayService.d.ts +5 -1
  137. package/dist/portal-gateway/gatewayService.js +25 -13
  138. package/dist/portal-gateway/image.d.ts +1 -1
  139. package/dist/portal-gateway/image.js +1 -1
  140. package/dist/portal-gateway/imageFiles.generated.js +1 -1
  141. package/dist/portal-gateway/portalService.d.ts +2 -0
  142. package/dist/portal-gateway/portalService.js +61 -29
  143. package/dist/portal-gateway/upstream.d.ts +22 -11
  144. package/dist/sandbox/api.d.ts +60 -23
  145. package/dist/sandbox/boatService.js +11 -3
  146. package/dist/sandbox/containerService.js +10 -3
  147. package/dist/sandbox/endpointProxy.js +16 -1
  148. package/dist/sandbox/localService.js +20 -2
  149. package/dist/sandbox/sshService.js +37 -4
  150. package/package.json +25 -8
package/dist/cli/agent.js CHANGED
@@ -101,6 +101,19 @@ export const agentCommands = {
101
101
  await forEach(backends, (backend) => agents.install(agent, backendArg(backend)));
102
102
  },
103
103
  },
104
+ backends: {
105
+ summary: "Show the backends installed in an agent's dimension",
106
+ args: ["AGENT"],
107
+ flags: QUIET_FLAG,
108
+ async run([agent], flags, { agents }) {
109
+ const rows = (await agents.listBackends(agent)).map(({ backend, version, ready }) => [
110
+ backend,
111
+ version ?? "-",
112
+ ready ? "ready" : version ? "incomplete or outdated: install it" : "not installed",
113
+ ]);
114
+ printList(flags, ["BACKEND", "VERSION", "STATUS"], rows);
115
+ },
116
+ },
104
117
  rm: {
105
118
  summary: "Stop one or more agents' workers (their sessions stay in the dimension)",
106
119
  args: ["NAME..."],
@@ -375,7 +388,11 @@ export const sessionCommands = {
375
388
  args: ["AGENT/SESSION"],
376
389
  flags: {
377
390
  limit: { type: "string", short: "n", value: "N", description: "How many messages, about (default 50)" },
378
- before: { type: "string", value: "CURSOR", description: "The page before this cursor (printed after each page that has older ones)" },
391
+ before: {
392
+ type: "string",
393
+ value: "TIME",
394
+ description: "Messages from before this time: ISO 8601 (2026-10-09T14:00:00Z, 2026-10-09), or how long ago (90s, 30m, 2h, 3d)",
395
+ },
379
396
  },
380
397
  async run([name], flags, { agents }) {
381
398
  const limit = typeof flags.limit === "string" ? Number(flags.limit) : undefined;
@@ -383,14 +400,14 @@ export const sessionCommands = {
383
400
  throw new UsageError(`--limit takes a positive number, not ${flags.limit}`);
384
401
  const page = await agents.getTranscript(sessionRef(name), {
385
402
  limit,
386
- cursor: typeof flags.before === "string" ? flags.before : undefined,
403
+ before: typeof flags.before === "string" ? timeFlag(flags.before) : undefined,
387
404
  });
388
405
  const printer = createTerminalPrinter({ paced: false });
389
406
  for (const { type, message } of page.messages)
390
407
  printer.printTranscript(type, message.content);
391
408
  printer.close();
392
- if (page.nextCursor)
393
- process.stderr.write(`(older: hyperbrane session logs ${name} --before ${page.nextCursor})\n`);
409
+ if (page.nextBefore)
410
+ process.stderr.write(`(older: hyperbrane session logs ${name} --before ${page.nextBefore})\n`);
394
411
  },
395
412
  },
396
413
  status: {
@@ -421,3 +438,12 @@ export const sessionCommands = {
421
438
  question: questionCommands,
422
439
  },
423
440
  };
441
+ const AGO_UNITS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
442
+ /** --before's TIME as an ISO 8601 time: a date or time, or how long ago ("30m"). */
443
+ function timeFlag(value) {
444
+ const ago = /^(\d+)([smhd])$/.exec(value);
445
+ const time = ago ? Date.now() - Number(ago[1]) * AGO_UNITS[ago[2]] : Date.parse(value);
446
+ if (Number.isNaN(time))
447
+ throw new UsageError(`--before takes a time (2026-10-09T14:00:00Z) or how long ago (2h), not ${value}`);
448
+ return new Date(time).toISOString();
449
+ }
@@ -4,12 +4,13 @@ import { type DockerClient } from "../lib/docker.ts";
4
4
  import type { Sandbox } from "../sandbox/api.ts";
5
5
  import { type GatewayService } from "../portal-gateway/gatewayService.ts";
6
6
  import { type PortalService } from "../portal-gateway/portalService.ts";
7
+ /** @sealed */
7
8
  export type Services = {
8
- docker: DockerClient;
9
- dimensions: DimensionService;
10
- gateways: GatewayService;
11
- portals: PortalService;
12
- agents: AgentService;
9
+ readonly docker: DockerClient;
10
+ readonly dimensions: DimensionService;
11
+ readonly gateways: GatewayService;
12
+ readonly portals: PortalService;
13
+ readonly agents: AgentService;
13
14
  };
14
15
  /**
15
16
  * Everything runs against `host`'s docker, with state in ~/.local/share/hyperbrane of its user: this
@@ -21,13 +21,14 @@ export function createServices(host = createLocalSandbox("/")) {
21
21
  const portalStorage = createPortalStorage(host);
22
22
  const gatewayImage = createGatewayImage(docker);
23
23
  const portals = createPortalService(portalStorage, gatewayStorage, docker, gatewayImage, host);
24
- const gateways = createGatewayService(gatewayStorage, portalStorage, docker, gatewayImage, host);
25
- const dimensions = createDimensionService(createFileStorage(host), docker, createDimensionImage(docker), host);
24
+ // Each restart they make, and each worker an agent revives, re-wires what it took: the portals.
25
+ const gateways = createGatewayService(gatewayStorage, portalStorage, docker, gatewayImage, host, portals);
26
+ const dimensions = createDimensionService(createFileStorage(host), docker, createDimensionImage(docker), host, portals);
26
27
  return {
27
28
  docker,
28
29
  dimensions,
29
30
  gateways,
30
31
  portals,
31
- agents: createAgentService(createAgentStorage(host), docker, host, createGatewaySignIns(portals, gateways)),
32
+ agents: createAgentService(createAgentStorage(host), docker, host, createGatewaySignIns(portals, gateways), portals),
32
33
  };
33
34
  }
@@ -28,14 +28,15 @@ export type DimensionLimits = {
28
28
  };
29
29
  export type DimensionStatus = "creating" | "missing" | "created" | "running" | "paused" | "restarting" | "removing" | "exited" | "dead";
30
30
  export type DimensionInfo = Dimension & {
31
- status: DimensionStatus;
31
+ readonly status: DimensionStatus;
32
32
  /** Null unless running. */
33
- uptimeSeconds: number | null;
33
+ readonly uptimeSeconds: number | null;
34
34
  /** Bytes. Null unless running. */
35
- memoryUsage: number | null;
35
+ readonly memoryUsage: number | null;
36
36
  /** Cores. Null unless running. */
37
- cpuUsage: number | null;
37
+ readonly cpuUsage: number | null;
38
38
  };
39
+ /** @sealed */
39
40
  export interface DimensionClient {
40
41
  /** POST /v1/dimensions */
41
42
  createDimension(dimension: Dimension): Promise<Dimension>;
@@ -3,3 +3,7 @@ export declare const WORKSPACE_PATH = "/workspace";
3
3
  export declare const RUN_PATH = "/run/hyperbrane";
4
4
  /** Where the dimension's state directory (see Storage.ensureStateDirectory) is mounted. */
5
5
  export declare const STATE_PATH = "/var/lib/hyperbrane";
6
+ /** On the host, under hyperbrane's data directory: a dimension's run directory, mounted at RUN_PATH. */
7
+ export declare function dimensionRunDirectory(dataDirectory: string, dimensionName: string): string;
8
+ /** On the host, under hyperbrane's data directory: a dimension's state directory, mounted at STATE_PATH. */
9
+ export declare function dimensionStateDirectory(dataDirectory: string, dimensionName: string): string;
@@ -1,7 +1,15 @@
1
- // Where things are inside every dimension. Its own module so code running in a dimension (the agent
2
- // worker) can use them without the docker client service.ts brings along.
1
+ // Where things are inside every dimension, and on the host. Its own module so code running in a
2
+ // dimension (the agent worker) can use them without the docker client service.ts brings along.
3
3
  export const WORKSPACE_PATH = "/workspace";
4
4
  /** Where the dimension's run directory (see Storage.ensureRunDirectory) is mounted. */
5
5
  export const RUN_PATH = "/run/hyperbrane";
6
6
  /** Where the dimension's state directory (see Storage.ensureStateDirectory) is mounted. */
7
7
  export const STATE_PATH = "/var/lib/hyperbrane";
8
+ /** On the host, under hyperbrane's data directory: a dimension's run directory, mounted at RUN_PATH. */
9
+ export function dimensionRunDirectory(dataDirectory, dimensionName) {
10
+ return `${dataDirectory}/dimension-run/${dimensionName}`;
11
+ }
12
+ /** On the host, under hyperbrane's data directory: a dimension's state directory, mounted at STATE_PATH. */
13
+ export function dimensionStateDirectory(dataDirectory, dimensionName) {
14
+ return `${dataDirectory}/dimension-state/${dimensionName}`;
15
+ }
@@ -4,7 +4,9 @@ import { type Dimension, type DimensionInfo, type DimensionLimits } from "./api.
4
4
  import type { DimensionImage } from "./image.ts";
5
5
  import type { Storage } from "./storage.ts";
6
6
  import { RUN_PATH, STATE_PATH, WORKSPACE_PATH } from "./paths.ts";
7
+ import type { PortalService } from "../portal-gateway/portalService.ts";
7
8
  export { RUN_PATH, STATE_PATH, WORKSPACE_PATH };
9
+ /** @sealed */
8
10
  export interface DimensionService {
9
11
  getDimensions(): Promise<DimensionInfo[]>;
10
12
  /** The dimensions' names, from the records alone: none of getDimensions' container status and usage. */
@@ -19,4 +21,6 @@ export declare function containerName(dimensionName: string): string;
19
21
  * `host` is the machine the docker daemon runs on, rooted at "/" so dimension directories
20
22
  * (bind-mount sources) resolve the same way docker resolves them.
21
23
  */
22
- export declare function createDimensionService(storage: Storage, docker: DockerClient, image: DimensionImage, host: Sandbox): DimensionService;
24
+ export declare function createDimensionService(storage: Storage, docker: DockerClient, image: DimensionImage, host: Sandbox,
25
+ /** Re-wires the portals of a dimension whose container restarted (portal-gateway/portalService.ts). */
26
+ restoring?: Pick<PortalService, "restorePortals">): DimensionService;
@@ -15,7 +15,9 @@ export function containerName(dimensionName) {
15
15
  * `host` is the machine the docker daemon runs on, rooted at "/" so dimension directories
16
16
  * (bind-mount sources) resolve the same way docker resolves them.
17
17
  */
18
- export function createDimensionService(storage, docker, image, host) {
18
+ export function createDimensionService(storage, docker, image, host,
19
+ /** Re-wires the portals of a dimension whose container restarted (portal-gateway/portalService.ts). */
20
+ restoring) {
19
21
  async function getDimensions() {
20
22
  return Promise.all((await storage.getDimensions()).map(describeDimension));
21
23
  }
@@ -159,6 +161,8 @@ export function createDimensionService(storage, docker, image, host) {
159
161
  throw new DimensionNotFoundError(`dimension ${dimensionName} has no container; create it again`);
160
162
  assertOwnedContainer(existing, dimensionName);
161
163
  await docker.restartContainer(containerName(dimensionName));
164
+ // The restart took its end of every portal with its network namespace.
165
+ await restoring?.restorePortals({ dimensionName });
162
166
  }
163
167
  // Container first: a limit docker refuses leaves the record as it was.
164
168
  async function updateDimension(dimensionName, limits) {
@@ -1,6 +1,7 @@
1
1
  import { dirname } from "node:path";
2
2
  import { createJsonDirectory } from "../lib/jsonDirectory.js";
3
3
  import { SandboxNotFoundError } from "../sandbox/api.js";
4
+ import { dimensionRunDirectory, dimensionStateDirectory } from "./paths.js";
4
5
  const EXTENSION = ".json";
5
6
  /**
6
7
  * `dir` is a sandbox path; defaults to ~/.local/share/hyperbrane/dimensions of the sandbox's user.
@@ -44,7 +45,7 @@ export function createFileStorage(sandbox, dir) {
44
45
  await sandbox.removeFile(await pathFor(dimensionName));
45
46
  }
46
47
  async function runDirectory(dimensionName) {
47
- return `${dirname(await storageDir())}/dimension-run/${dimensionName}`;
48
+ return dimensionRunDirectory(dirname(await storageDir()), dimensionName);
48
49
  }
49
50
  async function ensureRunDirectory(dimensionName) {
50
51
  const path = await runDirectory(dimensionName);
@@ -55,7 +56,7 @@ export function createFileStorage(sandbox, dir) {
55
56
  await sandbox.removeDirectory(await runDirectory(dimensionName));
56
57
  }
57
58
  async function stateDirectory(dimensionName) {
58
- return `${dirname(await storageDir())}/dimension-state/${dimensionName}`;
59
+ return dimensionStateDirectory(dirname(await storageDir()), dimensionName);
59
60
  }
60
61
  async function ensureStateDirectory(dimensionName) {
61
62
  const path = await stateDirectory(dimensionName);
package/dist/index.d.ts CHANGED
@@ -10,9 +10,8 @@ export { BOAT_API_URL, BoatError, archiveBoatMachine, authorizeBoatSshKey, boatH
10
10
  export { parseAccessibleHost, proxyCredentialsAuth, type SandboxEndpointAuth, type SandboxEndpointsOptions } from "./sandbox/endpointProxy.ts";
11
11
  export { exeDevProxyAuth, type ExeDevAuthOptions } from "./sandbox/exeDevAuth.ts";
12
12
  export { EndpointLoginRequiredError, endpointRequest, exeDevLoginUrl, openSseConnection, type EndpointAccess } from "./sandbox/sseConnection.ts";
13
- export { createWorkerClient, type WorkerClient } from "./agent/session/client.ts";
14
- export { workerSseConnector, type WorkerEndpoint } from "./agent/session/workerSseConnection.ts";
15
- export { sharedConnector, type SharedConnector } from "./agent/session/sharedConnection.ts";
13
+ export * from "./agent/client.ts";
14
+ export { createPortalWatcher, type PortalWatcher } from "./portal-gateway/portalService.ts";
16
15
  export { createDockerClient, DockerError, type DockerClient } from "./lib/docker.ts";
17
16
  export { BUILTIN_UPSTREAMS } from "./upstreams/index.ts";
18
17
  export { HYPERBRANE_VERSION } from "./lib/version.ts";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
- // The hyperbrane SDK's entry point: what an application drives a server with. Everything else is
2
- // reachable by its path too (`hyperbrane/sandbox/sshService`, ...).
1
+ // The hyperbrane SDK's entry point: what an application drives a server with. The package exports
2
+ // this and, for browsers (which can't take the server side along), each part's api and the worker
3
+ // client: hyperbrane/{sandbox,dimension,portal-gateway,agent}/api, hyperbrane/agent/client.
3
4
  // The APIs: types, errors and helpers of each part.
4
5
  export * from "./sandbox/api.js";
5
6
  export * from "./dimension/api.js";
@@ -17,9 +18,10 @@ export { parseAccessibleHost, proxyCredentialsAuth } from "./sandbox/endpointPro
17
18
  export { exeDevProxyAuth } from "./sandbox/exeDevAuth.js";
18
19
  export { EndpointLoginRequiredError, endpointRequest, exeDevLoginUrl, openSseConnection } from "./sandbox/sseConnection.js";
19
20
  // An agent's worker, reached through its endpoint (AgentService.exposeAgent), from a server or a browser.
20
- export { createWorkerClient } from "./agent/session/client.js";
21
- export { workerSseConnector } from "./agent/session/workerSseConnection.js";
22
- export { sharedConnector } from "./agent/session/sharedConnection.js";
21
+ export * from "./agent/client.js";
22
+ // Portals come back by themselves after the services' own restarts; after any other (docker's own, a
23
+ // reboot), restorePortals, or a watcher that runs it for each container that starts.
24
+ export { createPortalWatcher } from "./portal-gateway/portalService.js";
23
25
  export { createDockerClient, DockerError } from "./lib/docker.js";
24
26
  export { BUILTIN_UPSTREAMS } from "./upstreams/index.js";
25
27
  export { HYPERBRANE_VERSION } from "./lib/version.js";
@@ -34,6 +34,7 @@ export type BuildOptions = {
34
34
  context: Record<string, string>;
35
35
  labels?: Record<string, string>;
36
36
  };
37
+ /** @sealed */
37
38
  export interface DockerClient {
38
39
  imageExists(image: string): Promise<boolean>;
39
40
  buildImage(options: BuildOptions): Promise<void>;
@@ -18,10 +18,10 @@ export type Upstream = UpstreamDefinition;
18
18
  * with the key's fake, which is what a client in a dimension is given in place of the key.
19
19
  */
20
20
  export type GatewayCredential = {
21
- name: string;
22
- gatewayName: string;
23
- type: "oauth" | "apiKey";
24
- fake?: string;
21
+ readonly name: string;
22
+ readonly gatewayName: string;
23
+ readonly type: "oauth" | "apiKey";
24
+ readonly fake?: string;
25
25
  };
26
26
  /**
27
27
  * Applies to traffic arriving through the gateway's portals. "tcp://<address>:<port>" covers every
@@ -39,6 +39,7 @@ export type TrafficRule = {
39
39
  action: "BLOCK" | "DELAY" | "ASK" | "INTERCEPT";
40
40
  delayMs: number;
41
41
  };
42
+ /** @sealed */
42
43
  export interface PortalGatewayClient {
43
44
  /** POST /v1/gateways — also replaces a container made from an older gateway image, keeping its state. */
44
45
  createGateway(gateway: Gateway): Promise<Gateway>;
@@ -63,7 +64,7 @@ export interface PortalGatewayClient {
63
64
  * to give the client in the dimension (e.g. as its API key env var) in place of the key.
64
65
  */
65
66
  setApiKey(gatewayName: string, upstreamName: string, keyName: string, key: string): Promise<{
66
- fake: string;
67
+ readonly fake: string;
67
68
  }>;
68
69
  /** DELETE /v1/gateways/:gatewayName/upstreams/:upstreamName/api-keys/:keyName */
69
70
  deleteApiKey(gatewayName: string, upstreamName: string, keyName: string): Promise<void>;
@@ -4,6 +4,8 @@ import { type Sandbox } from "../sandbox/api.ts";
4
4
  import { type Gateway, type GatewayCredential, type TrafficRule, type Upstream } from "./api.ts";
5
5
  import type { GatewayStorage } from "./gatewayStorage.ts";
6
6
  import type { PortalStorage } from "./portalStorage.ts";
7
+ import type { PortalService } from "./portalService.ts";
8
+ /** @sealed */
7
9
  export interface GatewayService {
8
10
  createGateway(gateway: Gateway): Promise<Gateway>;
9
11
  destroyGateway(gatewayName: string): Promise<void>;
@@ -34,4 +36,6 @@ export declare function assertValidName(kind: string, name: string): void;
34
36
  * `host` is the machine the docker daemon runs on, rooted at "/" so the state directory (a
35
37
  * bind-mount source) resolves the same way docker resolves it.
36
38
  */
37
- export declare function createGatewayService(storage: GatewayStorage, portals: PortalStorage, docker: DockerClient, image: ManagedImage, host: Sandbox): GatewayService;
39
+ export declare function createGatewayService(storage: GatewayStorage, portals: PortalStorage, docker: DockerClient, image: ManagedImage, host: Sandbox,
40
+ /** Re-wires the portals of a gateway whose container restarted or was replaced. */
41
+ restoring?: Pick<PortalService, "restorePortals">): GatewayService;
@@ -10,6 +10,7 @@ import { BUILTIN_UPSTREAMS } from "../upstreams/index.js";
10
10
  /** Where the gateway's state directory is mounted in its container. */
11
11
  export const GATEWAY_STATE_PATH = "/state";
12
12
  const PROXY_PORT = 8080;
13
+ const DNS_PORT = 5353;
13
14
  const READY_TIMEOUT_MS = 60_000;
14
15
  // "hybgw-" can't collide with a dimension's "hyb-<name>": dimension names start with a letter or digit.
15
16
  export function gatewayContainerName(gatewayName) {
@@ -33,7 +34,9 @@ export function assertValidName(kind, name) {
33
34
  * `host` is the machine the docker daemon runs on, rooted at "/" so the state directory (a
34
35
  * bind-mount source) resolves the same way docker resolves it.
35
36
  */
36
- export function createGatewayService(storage, portals, docker, image, host) {
37
+ export function createGatewayService(storage, portals, docker, image, host,
38
+ /** Re-wires the portals of a gateway whose container restarted or was replaced. */
39
+ restoring) {
37
40
  // Keeps no state between calls: the record is written first so a slow or crashed create stays
38
41
  // visible, and every later step checks before it acts, so re-running converges.
39
42
  async function createGateway(gateway) {
@@ -53,11 +56,12 @@ export function createGatewayService(storage, portals, docker, image, host) {
53
56
  await awaitReady(gateway.name, stateDirectory);
54
57
  if (record.state !== "ready")
55
58
  await storage.saveGateway({ name: gateway.name, state: "ready" });
59
+ // A replaced container (an older image's) came without them; still wired, this is one look.
60
+ await restoring?.restorePortals({ gatewayName: gateway.name });
56
61
  return { name: gateway.name };
57
62
  }
58
63
  // A container from an older image is replaced: everything worth keeping (CA, credentials, rules)
59
- // is in the mounted state directory. Its portals are re-wired by the portal watcher, or by the
60
- // restore every command runs first.
64
+ // is in the mounted state directory. createGateway re-wires its portals once it's ready.
61
65
  async function ensureContainer(gatewayName, stateDirectory, imageTag) {
62
66
  const name = gatewayContainerName(gatewayName);
63
67
  const existing = await docker.inspectContainer(name);
@@ -104,8 +108,8 @@ export function createGatewayService(storage, portals, docker, image, host) {
104
108
  }
105
109
  throw conflict;
106
110
  }
107
- // Ready = the proxy is listening (so the redirect rules are in) and the CA exists (so portals can
108
- // install it into dimensions).
111
+ // Ready = the proxy and its resolver are listening (so the redirect rules are in, and nothing
112
+ // through a portal is refused) and the CA exists (so portals can install it into dimensions).
109
113
  async function awaitReady(gatewayName, stateDirectory) {
110
114
  const name = gatewayContainerName(gatewayName);
111
115
  const deadline = Date.now() + READY_TIMEOUT_MS;
@@ -119,8 +123,9 @@ export function createGatewayService(storage, portals, docker, image, host) {
119
123
  }
120
124
  async function proxyListening(name) {
121
125
  try {
122
- const result = await docker.exec(name, { cmd: ["ss", "-Hltn", `sport = :${PROXY_PORT}`], user: "root" });
123
- return result.exitCode === 0 && result.stdout.trim() !== "";
126
+ const result = await docker.exec(name, { cmd: ["ss", "-Hln", `( sport = :${PROXY_PORT} or sport = :${DNS_PORT} )`], user: "root" });
127
+ // Both: TCP for the proxy, UDP for the resolver.
128
+ return result.exitCode === 0 && /^tcp\b/m.test(result.stdout) && /^udp\b/m.test(result.stdout);
124
129
  }
125
130
  catch (error) {
126
131
  // Not running (yet, or restarting after a failed start).
@@ -165,6 +170,8 @@ export function createGatewayService(storage, portals, docker, image, host) {
165
170
  assertOwnedContainer(container, gatewayName);
166
171
  await docker.restartContainer(name);
167
172
  await awaitReady(gatewayName, await storage.ensureStateDirectory(record.name));
173
+ // The restart took its end of every portal with its network namespace.
174
+ await restoring?.restorePortals({ gatewayName });
168
175
  }
169
176
  async function listGateways() {
170
177
  return (await storage.listGateways()).map(({ name }) => ({ name }));
@@ -322,14 +329,19 @@ export function createGatewayService(storage, portals, docker, image, host) {
322
329
  /** The checks the gateway relies on; the rest of the shape is the SDK client's (see upstream.ts). */
323
330
  function validUpstream(upstream) {
324
331
  assertValidName("upstream", upstream.name);
325
- const { oauth, apiKey } = upstream.modes ?? {};
326
- if (!oauth && !apiKey)
327
- throw new GatewayInvalidError(`upstream ${upstream.name} needs an oauth or apiKey mode`);
332
+ const { oauth, apiKey, forced } = upstream.modes ?? {};
333
+ if (!oauth && !apiKey && !forced)
334
+ throw new GatewayInvalidError(`upstream ${upstream.name} needs an oauth, apiKey or forced mode`);
335
+ for (const mode of [oauth, apiKey, forced]) {
336
+ if (mode && (!Array.isArray(mode.hosts) || mode.hosts.length === 0))
337
+ throw new GatewayInvalidError(`upstream ${upstream.name}: every mode needs hosts`);
338
+ }
328
339
  for (const mode of [oauth, apiKey]) {
329
- if (mode && (!Array.isArray(mode.hosts) || mode.hosts.length === 0 || !mode.present)) {
330
- throw new GatewayInvalidError(`upstream ${upstream.name}: every mode needs hosts and present`);
331
- }
340
+ if (mode && !mode.present)
341
+ throw new GatewayInvalidError(`upstream ${upstream.name}: oauth and apiKey modes need present`);
332
342
  }
343
+ if (forced && !forced.force)
344
+ throw new GatewayInvalidError(`upstream ${upstream.name}: a forced mode needs force`);
333
345
  if (oauth && (!oauth.tokenUrl || !oauth.fakes?.access)) {
334
346
  throw new GatewayInvalidError(`upstream ${upstream.name}: oauth needs tokenUrl and fakes.access`);
335
347
  }
@@ -1,6 +1,6 @@
1
1
  import type { DockerClient } from "../lib/docker.ts";
2
2
  import { type ManagedImage } from "../lib/image.ts";
3
3
  /** Bump whenever anything under ./image changes (and run `bun run embed:images`). */
4
- export declare const GATEWAY_IMAGE_VERSION = "v16";
4
+ export declare const GATEWAY_IMAGE_VERSION = "v17";
5
5
  export declare const GATEWAY_IMAGE_REPOSITORY = "hyperbrane-gateway";
6
6
  export declare function createGatewayImage(docker: DockerClient): ManagedImage;
@@ -2,7 +2,7 @@ import { createManagedImage } from "../lib/image.js";
2
2
  // The files under ./image, as strings (see dimension/image.ts).
3
3
  import { IMAGE_FILES } from "./imageFiles.generated.js";
4
4
  /** Bump whenever anything under ./image changes (and run `bun run embed:images`). */
5
- export const GATEWAY_IMAGE_VERSION = "v16";
5
+ export const GATEWAY_IMAGE_VERSION = "v17";
6
6
  export const GATEWAY_IMAGE_REPOSITORY = "hyperbrane-gateway";
7
7
  export function createGatewayImage(docker) {
8
8
  return createManagedImage(docker, {
@@ -4,5 +4,5 @@ export const IMAGE_FILES = {
4
4
  "entry.sh": "#!/bin/sh\n# Gateway container entry: redirects everything arriving over a portal into mitmproxy, then becomes\n# mitmproxy. If the redirect can't be installed, or mitmproxy exits, the container exits: a gateway\n# that isn't intercepting must not look healthy.\nset -eu\n\nSTATE=/state\nPROXY_PORT=8080\nDNS_PORT=5353\n\n# Portals end in \"hvg*\" interfaces. All TCP goes to the transparent proxy and DNS to its resolver;\n# nothing is forwarded, so there is no path out that bypasses mitmproxy. mitmproxy's own upstream\n# connections are locally generated and never pass PREROUTING, so it doesn't intercept itself.\nnft -f - <<EOF\ntable ip hyperbrane\ndelete table ip hyperbrane\ntable ip hyperbrane {\n chain prerouting {\n type nat hook prerouting priority dstnat; policy accept;\n iifname \"hvg*\" meta l4proto tcp redirect to :$PROXY_PORT\n iifname \"hvg*\" udp dport 53 redirect to :$DNS_PORT\n }\n chain input {\n type filter hook input priority filter; policy accept;\n iifname \"hvg*\" tcp dport $PROXY_PORT accept\n iifname \"hvg*\" udp dport $DNS_PORT accept\n iifname \"hvg*\" drop\n }\n chain forward {\n type filter hook forward priority filter; policy accept;\n iifname \"hvg*\" drop\n }\n}\nEOF\n\n# An exit code of 0 is not proof: read the rules back.\nrules=\"$(nft list table ip hyperbrane)\"\ncase \"$rules\" in\n *\"redirect to :$PROXY_PORT\"*\"redirect to :$DNS_PORT\"*) ;;\n *) echo \"gateway: redirect rules are not installed\" >&2; exit 1 ;;\nesac\n\n# mitmproxy runs as the owner of the state directory, so everything it writes there (the CA) stays\n# readable and removable by the host service.\nowner_uid=\"$(stat -c %u \"$STATE\")\"\nowner_gid=\"$(stat -c %g \"$STATE\")\"\nexport HOME=/tmp\n# Eager: connect upstream as soon as the client connects, or protocols where the server speaks first\n# (SSH, SMTP) wait forever for a client that is waiting for them.\nexec setpriv --reuid=\"$owner_uid\" --regid=\"$owner_gid\" --clear-groups -- \\\n mitmdump \\\n --mode \"transparent@$PROXY_PORT\" \\\n --mode \"dns@$DNS_PORT\" \\\n --set \"confdir=$STATE/mitmproxy\" \\\n --set connection_strategy=eager \\\n -s /opt/hyperbrane/traffic_rules.py \\\n -s /opt/hyperbrane/upstreams.py\n",
5
5
  "wire-portal.sh": "#!/bin/sh\n# Wires a portal: a veth pair with one end in the dimension's network namespace and the other in the\n# gateway's, and a default route down it. A dimension with several portals has one default route per\n# portal; the lowest ROUTE_METRIC wins, and when its veth goes away (its gateway stopped) so does the\n# route, so traffic falls over to the next portal.\n# Runs in a privileged helper container sharing the host's network and pid namespaces, with the\n# host's /run at /hostrun.\n#\n# Inputs: PORTAL, DIMENSION_PID, GATEWAY_PID, DIMENSION_IF, GATEWAY_IF, DIMENSION_IP, GATEWAY_IP,\n# ROUTE_METRIC.\n# Safe to re-run: an already correct wiring is left alone, anything else is torn down and rebuilt.\nset -eu\n\n# Concurrent wirings of one portal would delete each other's veths; the lock lives on the host so it\n# is shared by every helper container.\nexec 9>\"/hostrun/hyperbrane-portal-$PORTAL.lock\"\nflock -w 30 9 || { echo \"timed out waiting for the wiring lock of $PORTAL\" >&2; exit 1; }\n\nmkdir -p /var/run/netns\nln -sf \"/proc/$DIMENSION_PID/ns/net\" /var/run/netns/dimension\nln -sf \"/proc/$GATEWAY_PID/ns/net\" /var/run/netns/gateway\ntrap 'rm -f /var/run/netns/dimension /var/run/netns/gateway' EXIT\n\nwired() {\n ip -n dimension addr show dev \"$DIMENSION_IF\" 2>/dev/null | grep -q \"inet $DIMENSION_IP/30\" \\\n && ip -n dimension route show default 2>/dev/null | grep -q \"via $GATEWAY_IP dev $DIMENSION_IF metric $ROUTE_METRIC\" \\\n && ip -n gateway addr show dev \"$GATEWAY_IF\" 2>/dev/null | grep -q \"inet $GATEWAY_IP/30\"\n}\nwired && exit 0\n\n# A stale end can be in either namespace, or left on the host when a container died mid-wiring.\nip -n dimension link del \"$DIMENSION_IF\" 2>/dev/null || true\nip -n gateway link del \"$GATEWAY_IF\" 2>/dev/null || true\nip link del \"$DIMENSION_IF\" 2>/dev/null || true\nip link del \"$GATEWAY_IF\" 2>/dev/null || true\n\nip link add \"$DIMENSION_IF\" type veth peer name \"$GATEWAY_IF\"\nip link set \"$DIMENSION_IF\" netns dimension\nip link set \"$GATEWAY_IF\" netns gateway\nip -n dimension addr replace \"$DIMENSION_IP/30\" dev \"$DIMENSION_IF\"\nip -n dimension link set \"$DIMENSION_IF\" up\nip -n gateway addr replace \"$GATEWAY_IP/30\" dev \"$GATEWAY_IF\"\nip -n gateway link set \"$GATEWAY_IF\" up\nip -n dimension route replace default via \"$GATEWAY_IP\" dev \"$DIMENSION_IF\" metric \"$ROUTE_METRIC\"\n",
6
6
  "traffic_rules.py": "# mitmproxy addon applying the gateway's traffic rules to everything arriving through a portal.\n#\n# Rules are the JSON files the gateway service writes to /state/traffic-rules, re-read every time, so\n# a change takes effect without a reload. A rule's urlMask is a glob, and its scheme says what it\n# applies to:\n# tcp://<address>:<port> every TCP connection, by its destination, before it is made upstream.\n# address is \"*\", an IPv4 address or a CIDR; port is \"*\" or a number.\n# Hostnames aren't known at that point, so rules don't use them.\n# anything else every HTTP(S) request, matched as a glob against host:port/path?query.\n# The scheme doesn't matter (http, https or none match the same), and a\n# mask without a port matches any port, one without a path any path:\n# \"github.com\" is the whole host, \"github.com:80/*\" only its plain HTTP.\n# Rules are checked in name order and the first match decides:\n# BLOCK refuse: a 403 for a request, a closed connection for TCP\n# DELAY hold for delayMs, then carry on\n# ASK hold until the service records an answer for the rule in /state/traffic-responses (only\n# answers given after the hold began count); DENY refuses, ALLOW carries on\n\nimport asyncio\nimport fnmatch\nimport ipaddress\nimport json\nimport os\nimport time\n\nfrom mitmproxy import http\nfrom mitmproxy.proxy import server_hooks\n\nSTATE = \"/state\"\nRULES = os.path.join(STATE, \"traffic-rules\")\nRESPONSES = os.path.join(STATE, \"traffic-responses\")\nASK_POLL_SECONDS = 0.2\nTCP_SCHEME = \"tcp://\"\n\n\ndef _load(path):\n try:\n with open(path) as f:\n return json.load(f)\n except (OSError, ValueError):\n return None\n\n\ndef _rules(tcp):\n try:\n files = sorted(f for f in os.listdir(RULES) if f.endswith(\".json\"))\n except OSError:\n return []\n rules = (_load(os.path.join(RULES, f)) for f in files)\n return [r for r in rules if r and r.get(\"urlMask\", \"\").startswith(TCP_SCHEME) == tcp]\n\n\ndef _request_target(request):\n \"\"\"host:port/path?query: the port always written out, the scheme left out.\"\"\"\n return f\"{request.pretty_host.lower()}:{request.port}{request.path}\"\n\n\ndef _url_mask(mask):\n \"\"\"The mask in _request_target's form: no scheme, and \"any\" for a port or path it leaves out.\"\"\"\n if \"://\" in mask:\n mask = mask.split(\"://\", 1)[1]\n host, slash, path = mask.partition(\"/\")\n if \":\" not in host:\n host += \":*\"\n return f\"{host.lower()}/{path if slash else '*'}\"\n\n\ndef _matching_url_rule(request):\n target = _request_target(request)\n for rule in _rules(tcp=False):\n if fnmatch.fnmatchcase(target, _url_mask(rule[\"urlMask\"])):\n return rule\n return None\n\n\ndef _tcp_mask_matches(mask, ip, port):\n address, _, mask_port = mask[len(TCP_SCHEME):].rpartition(\":\")\n if mask_port != \"*\" and mask_port != str(port):\n return False\n if address == \"*\":\n return True\n try:\n return ipaddress.ip_address(ip) in ipaddress.ip_network(address, strict=False)\n except ValueError:\n return False\n\n\ndef _matching_tcp_rule(ip, port):\n for rule in _rules(tcp=True):\n if _tcp_mask_matches(rule[\"urlMask\"], ip, port):\n return rule\n return None\n\n\nasync def _await_answer(name):\n \"\"\"The ALLOW/DENY given for the rule after now, or None once the rule is no longer an ASK.\"\"\"\n asked_at = time.time() * 1000\n while True:\n answer = _load(os.path.join(RESPONSES, f\"{name}.json\"))\n if answer and answer.get(\"at\", 0) >= asked_at:\n return answer.get(\"action\")\n rule = _load(os.path.join(RULES, f\"{name}.json\"))\n if not rule or rule.get(\"action\") != \"ASK\":\n return None\n # Never time.sleep: this runs on mitmproxy's event loop, shared by every connection.\n await asyncio.sleep(ASK_POLL_SECONDS)\n\n\nasync def _verdict(rule):\n \"\"\"Applies the rule's hold, if any; returns the refusal message, or None to carry on.\"\"\"\n action = rule.get(\"action\")\n if action == \"BLOCK\":\n return f\"blocked by gateway rule {rule['name']}\"\n if action == \"DELAY\":\n await asyncio.sleep(max(0, rule.get(\"delayMs\", 0)) / 1000)\n elif action == \"ASK\" and await _await_answer(rule[\"name\"]) == \"DENY\":\n return f\"denied by gateway rule {rule['name']}\"\n return None\n\n\nasync def request(flow: http.HTTPFlow):\n rule = _matching_url_rule(flow.request)\n refusal = rule and await _verdict(rule)\n if refusal:\n flow.response = http.Response.make(403, f\"{refusal}\\n\", {\"content-type\": \"text/plain\"})\n\n\nasync def server_connect(data: server_hooks.ServerConnectionHookData):\n if not data.server.address:\n return\n rule = _matching_tcp_rule(data.server.address[0], data.server.address[1])\n refusal = rule and await _verdict(rule)\n if refusal:\n data.server.error = refusal\n\n",
7
- "upstreams.py": "# mitmproxy addon applying the gateway's upstreams (see portal-gateway/upstream.ts) to traffic from\n# portals. Definitions are the JSON files the gateway service writes to /state/upstreams; this addon\n# keeps each upstream's OAuth credential in /state/credentials/<upstream>.oauth.json and reads its\n# API keys from /state/credentials/<upstream>.api-keys.json. Everything is re-read per request, so a\n# change takes effect without a reload.\n#\n# Clients sign in from dimensions, through the proxy:\n# token endpoint without a usable credential, requests go through and the response is kept as\n# the credential, reaching the dimension with fakes in place of the tokens. With\n# one, the gateway answers from it: a sign-in (whatever code it carries) and a\n# refresh made with a fake refresh token. A client's refresh goes upstream: the\n# gateway replays it (encoding, fields, User-Agent) with the real refresh token,\n# one at a time, and refreshes arriving together share its result. Every client\n# holds the same fakes, so they all get the new tokens. A sign-in is answered from\n# the credential, refreshed first if expired by replaying the last client refresh.\n# An expired credential it can't refresh (no client refresh to replay yet, or\n# upstream refused) counts as none: a sign-in goes to the upstream again.\n# answers other sign-in steps (a device flow's poll) answered as the definition says, once\n# there is a credential\n# other requests a fake in the mode's `present` location is replaced by the real secret\n\nimport asyncio\nimport http.client as http_client\nimport json\nimport os\nimport secrets\nimport string\nimport time\nimport urllib.parse\n\nfrom mitmproxy import http\n\nSTATE = \"/state\"\nUPSTREAMS = os.path.join(STATE, \"upstreams\")\nCREDENTIALS = os.path.join(STATE, \"credentials\")\n# A token this close to expiry counts as expired.\nREFRESH_MARGIN_MS = 60_000\n# Refreshes arriving within this long of the last one get its tokens instead of going upstream again.\nREFRESH_SHARED_MS = 5_000\nFAKE_ALPHABET = string.ascii_letters + string.digits + \"-_\"\nTOKEN_FIELDS = ((\"access\", \"access_token\"), (\"refresh\", \"refresh_token\"), (\"id\", \"id_token\"))\n\n# One refresh per upstream at a time: concurrent ones would each spend the rotating refresh token.\n_refresh_locks: dict[str, asyncio.Lock] = {}\n\n\ndef _now_ms():\n return int(time.time() * 1000)\n\n\ndef _load(path):\n try:\n with open(path) as f:\n return json.load(f)\n except (OSError, ValueError):\n return None\n\n\ndef _save(path, data):\n tmp = f\"{path}.{secrets.token_hex(4)}.tmp\"\n with open(tmp, \"w\") as f:\n json.dump(data, f, indent=2)\n os.replace(tmp, path)\n\n\ndef _definitions():\n try:\n files = sorted(f for f in os.listdir(UPSTREAMS) if f.endswith(\".json\"))\n except OSError:\n return []\n definitions = (_load(os.path.join(UPSTREAMS, f)) for f in files)\n return [d for d in definitions if d and d.get(\"name\")]\n\n\ndef _oauth_path(name):\n return os.path.join(CREDENTIALS, f\"{name}.oauth.json\")\n\n\ndef _keys_path(name):\n return os.path.join(CREDENTIALS, f\"{name}.api-keys.json\")\n\n\ndef _base(url):\n return url.split(\"?\", 1)[0].split(\"#\", 1)[0]\n\n\ndef _hosts(mode):\n return {h.lower() for h in mode.get(\"hosts\", [])}\n\n\n# --- fakes -----------------------------------------------------------------------------------------\n\n\ndef _random(length):\n return \"\".join(secrets.choice(FAKE_ALPHABET) for _ in range(length))\n\n\ndef _issue_fake(shape, real):\n if shape.get(\"jwt\"):\n signature = real.rsplit(\".\", 1)[-1] if real.count(\".\") == 2 else \"\"\n return {\"jwtSignature\": _random(max(len(signature), 43))}\n prefix = shape.get(\"prefix\", \"\")\n return {\"value\": prefix + _random(max(len(real) - len(prefix), 16))}\n\n\ndef _fake_value(stored, real):\n if \"jwtSignature\" in stored:\n header_and_claims = real.rsplit(\".\", 1)[0] if real.count(\".\") == 2 else real\n return f\"{header_and_claims}.{stored['jwtSignature']}\"\n return stored[\"value\"]\n\n\ndef _is_fake(stored, candidate):\n if not stored or not candidate:\n return False\n if \"jwtSignature\" in stored:\n return candidate.count(\".\") == 2 and candidate.rsplit(\".\", 1)[1] == stored[\"jwtSignature\"]\n return candidate == stored[\"value\"]\n\n\n# --- presenting secrets ----------------------------------------------------------------------------\n\n\ndef _presented(request, present):\n if \"query\" in present:\n return request.query.get(present[\"query\"])\n value = request.headers.get(present[\"header\"])\n scheme = present.get(\"scheme\")\n if value and scheme:\n prefix = f\"{scheme} \"\n return value[len(prefix):] if value.lower().startswith(prefix.lower()) else None\n return value\n\n\ndef _present(request, present, secret):\n if \"query\" in present:\n request.query[present[\"query\"]] = secret\n elif present.get(\"scheme\"):\n request.headers[present[\"header\"]] = f\"{present['scheme']} {secret}\"\n else:\n request.headers[present[\"header\"]] = secret\n\n\ndef _force(request, force):\n if not force:\n return\n if force.get(\"target\"):\n target = urllib.parse.urlsplit(force[\"target\"])\n request.scheme = target.scheme or request.scheme\n request.host = target.hostname or request.host\n request.port = target.port or (443 if request.scheme == \"https\" else 80)\n if force.get(\"path\"):\n request.path = force[\"path\"].rstrip(\"/\") + request.path\n for key, value in force.get(\"query\", {}).items():\n request.query[key] = value\n for key, value in force.get(\"headers\", {}).items():\n request.headers[key] = value\n\n\ndef _swap_oauth(request, name, mode):\n credential = _load(_oauth_path(name))\n presented = _presented(request, mode[\"present\"])\n if credential and _is_fake(credential[\"fakes\"].get(\"access\"), presented):\n _present(request, mode[\"present\"], credential[\"access\"])\n return True\n return False\n\n\ndef _swap_api_key(request, name, mode):\n stored = _load(_keys_path(name)) or {}\n presented = _presented(request, mode[\"present\"])\n for key in stored.get(\"keys\", {}).values():\n if presented and presented == key[\"fake\"]:\n _present(request, mode[\"present\"], key[\"key\"])\n return True\n return False\n\n\n# --- the token endpoint ----------------------------------------------------------------------------\n\n\ndef _parse_form_or_json(text, content_type):\n if not text:\n return {}\n if \"json\" in (content_type or \"\"):\n try:\n value = json.loads(text)\n return value if isinstance(value, dict) else {}\n except ValueError:\n return {}\n return dict(urllib.parse.parse_qsl(text))\n\n\ndef _refresh_request(flow, fields):\n \"\"\"How a client refreshes, to replay with the real refresh token: everything but that token.\"\"\"\n return {\n \"contentType\": flow.request.headers.get(\"content-type\", \"\"),\n \"userAgent\": flow.request.headers.get(\"user-agent\"),\n \"fields\": {k: v for k, v in fields.items() if k != \"refresh_token\"},\n }\n\n\ndef _store_tokens(name, mode, body, refresh_request):\n \"\"\"Keeps a token response as the upstream's credential and returns it.\"\"\"\n path = _oauth_path(name)\n credential = dict(_load(path) or {})\n if refresh_request:\n credential[\"refreshRequest\"] = refresh_request\n now = _now_ms()\n for key, field in TOKEN_FIELDS:\n if body.get(field):\n credential[key] = body[field]\n if \"expires_in\" in body:\n credential[\"expiresAt\"] = now + int(body[\"expires_in\"]) * 1000\n if \"refresh_token_expires_in\" in body:\n credential[\"refreshExpiresAt\"] = now + int(body[\"refresh_token_expires_in\"]) * 1000\n if body.get(\"scope\"):\n credential[\"scopes\"] = body[\"scope\"].split()\n credential[\"lastRefresh\"] = now\n fakes = dict(credential.get(\"fakes\") or {})\n for key, _ in TOKEN_FIELDS:\n shape = mode[\"fakes\"].get(key)\n if shape and credential.get(key) and key not in fakes:\n fakes[key] = _issue_fake(shape, credential[key])\n credential[\"fakes\"] = fakes\n stripped = set((mode.get(\"strip\") or {}).get(\"fields\", []))\n credential[\"response\"] = {k: v for k, v in body.items() if k not in stripped}\n _save(path, credential)\n return credential\n\n\ndef _client_body(mode, credential):\n \"\"\"The token response the dimension gets: the last real one, with fakes for every token.\"\"\"\n body = dict(credential.get(\"response\") or {})\n fakes = credential[\"fakes\"]\n for key, field in TOKEN_FIELDS:\n if field not in body:\n continue\n if key in fakes and credential.get(key):\n body[field] = _fake_value(fakes[key], credential[key])\n else:\n del body[field] # a token with no fake shape never reaches a dimension\n now = _now_ms()\n if credential.get(\"expiresAt\") and \"expires_in\" in body:\n body[\"expires_in\"] = max(0, (credential[\"expiresAt\"] - now) // 1000)\n if credential.get(\"refreshExpiresAt\") and \"refresh_token_expires_in\" in body:\n body[\"refresh_token_expires_in\"] = max(0, (credential[\"refreshExpiresAt\"] - now) // 1000)\n return body\n\n\ndef _refresh_upstream(mode, credential):\n \"\"\"Replays the client's refresh request with the real refresh token; returns (status, parsed body\n or raw bytes). http.client, unlike urllib, adds no User-Agent of its own: a client that sent none\n stays that way.\"\"\"\n template = credential[\"refreshRequest\"]\n fields = dict(template[\"fields\"], refresh_token=credential[\"refresh\"])\n content_type = template[\"contentType\"] or \"application/x-www-form-urlencoded\"\n data = json.dumps(fields) if \"json\" in content_type else urllib.parse.urlencode(fields)\n headers = {\"content-type\": content_type, \"accept\": \"application/json\"}\n if template.get(\"userAgent\"):\n headers[\"user-agent\"] = template[\"userAgent\"]\n url = urllib.parse.urlsplit(mode[\"tokenUrl\"])\n connection_type = http_client.HTTPSConnection if url.scheme == \"https\" else http_client.HTTPConnection\n connection = connection_type(url.hostname, url.port, timeout=30)\n try:\n connection.request(\"POST\", url.path + (f\"?{url.query}\" if url.query else \"\"), body=data.encode(), headers=headers)\n response = connection.getresponse()\n raw = response.read()\n try:\n return response.status, json.loads(raw)\n except ValueError:\n return response.status, raw\n finally:\n connection.close()\n\n\nasync def _answer_from_credential(flow, name, mode, refresh_request):\n \"\"\"Answers a sign-in, or a refresh made with a fake (`refresh_request`), with the credential's\n fakes. A refresh is replayed upstream first unless another one just was; a sign-in only if the\n credential has expired. Returns False, answering nothing, when a sign-in can't be given valid\n tokens: expired, and no client refresh to replay yet or upstream refused it. A client's refresh\n gets upstream's refusal instead.\"\"\"\n lock = _refresh_locks.setdefault(name, asyncio.Lock())\n async with lock:\n credential = _load(_oauth_path(name))\n if refresh_request and credential.get(\"refreshRequest\") != refresh_request:\n credential[\"refreshRequest\"] = refresh_request\n _save(_oauth_path(name), credential)\n now = _now_ms()\n expired = not credential.get(\"expiresAt\") or credential[\"expiresAt\"] - now < REFRESH_MARGIN_MS\n just_refreshed = now - credential.get(\"lastRefresh\", 0) < REFRESH_SHARED_MS\n if expired or (refresh_request and not just_refreshed):\n if not (credential.get(\"refresh\") and credential.get(\"refreshRequest\")):\n if not refresh_request:\n return False\n # A client's refresh with nothing to refresh with: all there is to give it.\n flow.response = http.Response.make(\n 200, json.dumps(_client_body(mode, credential)), {\"content-type\": \"application/json\"}\n )\n return True\n status, body = await asyncio.to_thread(_refresh_upstream, mode, credential)\n if status != 200 or not isinstance(body, dict) or \"access_token\" not in body:\n if not refresh_request:\n return False\n content = body if isinstance(body, bytes) else json.dumps(body).encode()\n flow.response = http.Response.make(status, content, {\"content-type\": \"application/json\"})\n return True\n credential = _store_tokens(name, mode, body, None)\n flow.response = http.Response.make(\n 200, json.dumps(_client_body(mode, credential)), {\"content-type\": \"application/json\"}\n )\n return True\n\n\nasync def request(flow: http.HTTPFlow):\n if flow.response: # already answered, e.g. by a traffic rule\n return\n host = flow.request.pretty_host.lower()\n url = _base(flow.request.pretty_url)\n for definition in _definitions():\n name = definition[\"name\"]\n oauth = definition.get(\"modes\", {}).get(\"oauth\")\n api_key = definition.get(\"modes\", {}).get(\"apiKey\")\n credential = _load(_oauth_path(name)) if oauth else None\n if oauth and url == _base(oauth[\"tokenUrl\"]):\n fields = _parse_form_or_json(flow.request.get_text(strict=False), flow.request.headers.get(\"content-type\"))\n refreshing = fields.get(\"grant_type\") == \"refresh_token\"\n refresh_request = _refresh_request(flow, fields) if refreshing else None\n # A refresh token the gateway didn't issue is a client that signed in before the upstream\n # was registered (or elsewhere): let it through, and keep what it gets.\n foreign_refresh = refreshing and not _is_fake(\n (credential or {}).get(\"fakes\", {}).get(\"refresh\"), fields.get(\"refresh_token\")\n )\n if credential and not foreign_refresh and await _answer_from_credential(flow, name, oauth, refresh_request):\n return\n flow.metadata[\"hyperbrane_token\"] = (name, refresh_request)\n return\n if credential:\n for answer in oauth.get(\"answers\", []):\n if url == _base(answer[\"url\"]):\n flow.response = http.Response.make(\n answer.get(\"status\", 200), json.dumps(answer[\"json\"]), {\"content-type\": \"application/json\"}\n )\n return\n for mode, swap in ((oauth, _swap_oauth), (api_key, _swap_api_key)):\n if mode and host in _hosts(mode):\n swap(flow.request, name, mode)\n _force(flow.request, mode.get(\"force\"))\n\n\ndef responseheaders(flow: http.HTTPFlow):\n # Only token responses are rewritten; everything else streams through as it arrives. mitmproxy\n # otherwise holds each response until its body is complete, so a model's streamed reply (SSE)\n # reached the dimension in one piece at the end of the turn.\n if not flow.metadata.get(\"hyperbrane_token\"):\n flow.response.stream = True\n\n\ndef response(flow: http.HTTPFlow):\n token = flow.metadata.get(\"hyperbrane_token\")\n if not token:\n return\n name, refresh_request = token\n definition = next((d for d in _definitions() if d[\"name\"] == name), None)\n oauth = (definition or {}).get(\"modes\", {}).get(\"oauth\")\n if not oauth:\n return\n body = _parse_form_or_json(flow.response.get_text(strict=False), \"json\")\n if flow.response.status_code == 200 and body.get(\"access_token\"):\n credential = _store_tokens(name, oauth, body, refresh_request)\n flow.response.text = json.dumps(_client_body(oauth, credential))\n for header in (oauth.get(\"strip\") or {}).get(\"headers\", []):\n flow.response.headers.pop(header, None)\n",
7
+ "upstreams.py": "# mitmproxy addon applying the gateway's upstreams (see portal-gateway/upstream.ts) to traffic from\n# portals. Definitions are the JSON files the gateway service writes to /state/upstreams; this addon\n# keeps each upstream's OAuth credential in /state/credentials/<upstream>.oauth.json and reads its\n# API keys from /state/credentials/<upstream>.api-keys.json. Everything is re-read per request, so a\n# change takes effect without a reload.\n#\n# Clients sign in from dimensions, through the proxy:\n# token endpoint without a usable credential, requests go through and the response is kept as\n# the credential, reaching the dimension with fakes in place of the tokens. With\n# one, the gateway answers from it: a sign-in (whatever code it carries) and a\n# refresh made with a fake refresh token. A client's refresh goes upstream: the\n# gateway replays it (encoding, fields, User-Agent) with the real refresh token,\n# one at a time, and refreshes arriving together share its result. Every client\n# holds the same fakes, so they all get the new tokens. A sign-in is answered from\n# the credential, refreshed first if expired by replaying the last client refresh.\n# An expired credential it can't refresh (no client refresh to replay yet, or\n# upstream refused) counts as none: a sign-in goes to the upstream again.\n# answers other sign-in steps (a device flow's poll) answered as the definition says, once\n# there is a credential\n# other requests a fake in the mode's `present` location is replaced by the real secret\n\nimport asyncio\nimport http.client as http_client\nimport json\nimport os\nimport secrets\nimport string\nimport time\nimport urllib.parse\n\nfrom mitmproxy import http\n\nSTATE = \"/state\"\nUPSTREAMS = os.path.join(STATE, \"upstreams\")\nCREDENTIALS = os.path.join(STATE, \"credentials\")\n# A token this close to expiry counts as expired.\nREFRESH_MARGIN_MS = 60_000\n# Refreshes arriving within this long of the last one get its tokens instead of going upstream again.\nREFRESH_SHARED_MS = 5_000\nFAKE_ALPHABET = string.ascii_letters + string.digits + \"-_\"\nTOKEN_FIELDS = ((\"access\", \"access_token\"), (\"refresh\", \"refresh_token\"), (\"id\", \"id_token\"))\n\n# One refresh per upstream at a time: concurrent ones would each spend the rotating refresh token.\n_refresh_locks: dict[str, asyncio.Lock] = {}\n\n\ndef _now_ms():\n return int(time.time() * 1000)\n\n\ndef _load(path):\n try:\n with open(path) as f:\n return json.load(f)\n except (OSError, ValueError):\n return None\n\n\ndef _save(path, data):\n tmp = f\"{path}.{secrets.token_hex(4)}.tmp\"\n with open(tmp, \"w\") as f:\n json.dump(data, f, indent=2)\n os.replace(tmp, path)\n\n\ndef _definitions():\n try:\n files = sorted(f for f in os.listdir(UPSTREAMS) if f.endswith(\".json\"))\n except OSError:\n return []\n definitions = (_load(os.path.join(UPSTREAMS, f)) for f in files)\n return [d for d in definitions if d and d.get(\"name\")]\n\n\ndef _oauth_path(name):\n return os.path.join(CREDENTIALS, f\"{name}.oauth.json\")\n\n\ndef _keys_path(name):\n return os.path.join(CREDENTIALS, f\"{name}.api-keys.json\")\n\n\ndef _base(url):\n return url.split(\"?\", 1)[0].split(\"#\", 1)[0]\n\n\ndef _hosts(mode):\n return {h.lower() for h in mode.get(\"hosts\", [])}\n\n\n# --- fakes -----------------------------------------------------------------------------------------\n\n\ndef _random(length):\n return \"\".join(secrets.choice(FAKE_ALPHABET) for _ in range(length))\n\n\ndef _issue_fake(shape, real):\n if shape.get(\"jwt\"):\n signature = real.rsplit(\".\", 1)[-1] if real.count(\".\") == 2 else \"\"\n return {\"jwtSignature\": _random(max(len(signature), 43))}\n prefix = shape.get(\"prefix\", \"\")\n return {\"value\": prefix + _random(max(len(real) - len(prefix), 16))}\n\n\ndef _fake_value(stored, real):\n if \"jwtSignature\" in stored:\n header_and_claims = real.rsplit(\".\", 1)[0] if real.count(\".\") == 2 else real\n return f\"{header_and_claims}.{stored['jwtSignature']}\"\n return stored[\"value\"]\n\n\ndef _is_fake(stored, candidate):\n if not stored or not candidate:\n return False\n if \"jwtSignature\" in stored:\n return candidate.count(\".\") == 2 and candidate.rsplit(\".\", 1)[1] == stored[\"jwtSignature\"]\n return candidate == stored[\"value\"]\n\n\n# --- presenting secrets ----------------------------------------------------------------------------\n\n\ndef _presented(request, present):\n if \"query\" in present:\n return request.query.get(present[\"query\"])\n value = request.headers.get(present[\"header\"])\n scheme = present.get(\"scheme\")\n if value and scheme:\n prefix = f\"{scheme} \"\n return value[len(prefix):] if value.lower().startswith(prefix.lower()) else None\n return value\n\n\ndef _present(request, present, secret):\n if \"query\" in present:\n request.query[present[\"query\"]] = secret\n elif present.get(\"scheme\"):\n request.headers[present[\"header\"]] = f\"{present['scheme']} {secret}\"\n else:\n request.headers[present[\"header\"]] = secret\n\n\ndef _force(request, force):\n if not force:\n return\n if force.get(\"target\"):\n target = urllib.parse.urlsplit(force[\"target\"])\n request.scheme = target.scheme or request.scheme\n request.host = target.hostname or request.host\n request.port = target.port or (443 if request.scheme == \"https\" else 80)\n if force.get(\"path\"):\n request.path = force[\"path\"].rstrip(\"/\") + request.path\n for key, value in force.get(\"query\", {}).items():\n request.query[key] = value\n for key, value in force.get(\"headers\", {}).items():\n request.headers[key] = value\n\n\ndef _swap_oauth(request, name, mode):\n credential = _load(_oauth_path(name))\n presented = _presented(request, mode[\"present\"])\n if credential and _is_fake(credential[\"fakes\"].get(\"access\"), presented):\n _present(request, mode[\"present\"], credential[\"access\"])\n return True\n return False\n\n\ndef _swap_api_key(request, name, mode):\n stored = _load(_keys_path(name)) or {}\n presented = _presented(request, mode[\"present\"])\n for key in stored.get(\"keys\", {}).values():\n if presented and presented == key[\"fake\"]:\n _present(request, mode[\"present\"], key[\"key\"])\n return True\n return False\n\n\n# --- the token endpoint ----------------------------------------------------------------------------\n\n\ndef _parse_form_or_json(text, content_type):\n if not text:\n return {}\n if \"json\" in (content_type or \"\"):\n try:\n value = json.loads(text)\n return value if isinstance(value, dict) else {}\n except ValueError:\n return {}\n return dict(urllib.parse.parse_qsl(text))\n\n\ndef _refresh_request(flow, fields):\n \"\"\"How a client refreshes, to replay with the real refresh token: everything but that token.\"\"\"\n return {\n \"contentType\": flow.request.headers.get(\"content-type\", \"\"),\n \"userAgent\": flow.request.headers.get(\"user-agent\"),\n \"fields\": {k: v for k, v in fields.items() if k != \"refresh_token\"},\n }\n\n\ndef _store_tokens(name, mode, body, refresh_request):\n \"\"\"Keeps a token response as the upstream's credential and returns it.\"\"\"\n path = _oauth_path(name)\n credential = dict(_load(path) or {})\n if refresh_request:\n credential[\"refreshRequest\"] = refresh_request\n now = _now_ms()\n for key, field in TOKEN_FIELDS:\n if body.get(field):\n credential[key] = body[field]\n if \"expires_in\" in body:\n credential[\"expiresAt\"] = now + int(body[\"expires_in\"]) * 1000\n if \"refresh_token_expires_in\" in body:\n credential[\"refreshExpiresAt\"] = now + int(body[\"refresh_token_expires_in\"]) * 1000\n if body.get(\"scope\"):\n credential[\"scopes\"] = body[\"scope\"].split()\n credential[\"lastRefresh\"] = now\n fakes = dict(credential.get(\"fakes\") or {})\n for key, _ in TOKEN_FIELDS:\n shape = mode[\"fakes\"].get(key)\n if shape and credential.get(key) and key not in fakes:\n fakes[key] = _issue_fake(shape, credential[key])\n credential[\"fakes\"] = fakes\n stripped = set((mode.get(\"strip\") or {}).get(\"fields\", []))\n credential[\"response\"] = {k: v for k, v in body.items() if k not in stripped}\n _save(path, credential)\n return credential\n\n\ndef _client_body(mode, credential):\n \"\"\"The token response the dimension gets: the last real one, with fakes for every token.\"\"\"\n body = dict(credential.get(\"response\") or {})\n fakes = credential[\"fakes\"]\n for key, field in TOKEN_FIELDS:\n if field not in body:\n continue\n if key in fakes and credential.get(key):\n body[field] = _fake_value(fakes[key], credential[key])\n else:\n del body[field] # a token with no fake shape never reaches a dimension\n now = _now_ms()\n if credential.get(\"expiresAt\") and \"expires_in\" in body:\n body[\"expires_in\"] = max(0, (credential[\"expiresAt\"] - now) // 1000)\n if credential.get(\"refreshExpiresAt\") and \"refresh_token_expires_in\" in body:\n body[\"refresh_token_expires_in\"] = max(0, (credential[\"refreshExpiresAt\"] - now) // 1000)\n return body\n\n\ndef _refresh_upstream(mode, credential):\n \"\"\"Replays the client's refresh request with the real refresh token; returns (status, parsed body\n or raw bytes). http.client, unlike urllib, adds no User-Agent of its own: a client that sent none\n stays that way.\"\"\"\n template = credential[\"refreshRequest\"]\n fields = dict(template[\"fields\"], refresh_token=credential[\"refresh\"])\n content_type = template[\"contentType\"] or \"application/x-www-form-urlencoded\"\n data = json.dumps(fields) if \"json\" in content_type else urllib.parse.urlencode(fields)\n headers = {\"content-type\": content_type, \"accept\": \"application/json\"}\n if template.get(\"userAgent\"):\n headers[\"user-agent\"] = template[\"userAgent\"]\n url = urllib.parse.urlsplit(mode[\"tokenUrl\"])\n connection_type = http_client.HTTPSConnection if url.scheme == \"https\" else http_client.HTTPConnection\n connection = connection_type(url.hostname, url.port, timeout=30)\n try:\n connection.request(\"POST\", url.path + (f\"?{url.query}\" if url.query else \"\"), body=data.encode(), headers=headers)\n response = connection.getresponse()\n raw = response.read()\n try:\n return response.status, json.loads(raw)\n except ValueError:\n return response.status, raw\n finally:\n connection.close()\n\n\nasync def _answer_from_credential(flow, name, mode, refresh_request):\n \"\"\"Answers a sign-in, or a refresh made with a fake (`refresh_request`), with the credential's\n fakes. A refresh is replayed upstream first unless another one just was; a sign-in only if the\n credential has expired. Returns False, answering nothing, when a sign-in can't be given valid\n tokens: expired, and no client refresh to replay yet or upstream refused it. A client's refresh\n gets upstream's refusal instead.\"\"\"\n lock = _refresh_locks.setdefault(name, asyncio.Lock())\n async with lock:\n credential = _load(_oauth_path(name))\n if refresh_request and credential.get(\"refreshRequest\") != refresh_request:\n credential[\"refreshRequest\"] = refresh_request\n _save(_oauth_path(name), credential)\n now = _now_ms()\n expired = not credential.get(\"expiresAt\") or credential[\"expiresAt\"] - now < REFRESH_MARGIN_MS\n just_refreshed = now - credential.get(\"lastRefresh\", 0) < REFRESH_SHARED_MS\n if expired or (refresh_request and not just_refreshed):\n if not (credential.get(\"refresh\") and credential.get(\"refreshRequest\")):\n if not refresh_request:\n return False\n # A client's refresh with nothing to refresh with: all there is to give it.\n flow.response = http.Response.make(\n 200, json.dumps(_client_body(mode, credential)), {\"content-type\": \"application/json\"}\n )\n return True\n status, body = await asyncio.to_thread(_refresh_upstream, mode, credential)\n if status != 200 or not isinstance(body, dict) or \"access_token\" not in body:\n if not refresh_request:\n return False\n content = body if isinstance(body, bytes) else json.dumps(body).encode()\n flow.response = http.Response.make(status, content, {\"content-type\": \"application/json\"})\n return True\n credential = _store_tokens(name, mode, body, None)\n flow.response = http.Response.make(\n 200, json.dumps(_client_body(mode, credential)), {\"content-type\": \"application/json\"}\n )\n return True\n\n\nasync def request(flow: http.HTTPFlow):\n if flow.response: # already answered, e.g. by a traffic rule\n return\n host = flow.request.pretty_host.lower()\n url = _base(flow.request.pretty_url)\n for definition in _definitions():\n name = definition[\"name\"]\n oauth = definition.get(\"modes\", {}).get(\"oauth\")\n api_key = definition.get(\"modes\", {}).get(\"apiKey\")\n forced = definition.get(\"modes\", {}).get(\"forced\")\n credential = _load(_oauth_path(name)) if oauth else None\n if oauth and url == _base(oauth[\"tokenUrl\"]):\n fields = _parse_form_or_json(flow.request.get_text(strict=False), flow.request.headers.get(\"content-type\"))\n refreshing = fields.get(\"grant_type\") == \"refresh_token\"\n refresh_request = _refresh_request(flow, fields) if refreshing else None\n # A refresh token the gateway didn't issue is a client that signed in before the upstream\n # was registered (or elsewhere): let it through, and keep what it gets.\n foreign_refresh = refreshing and not _is_fake(\n (credential or {}).get(\"fakes\", {}).get(\"refresh\"), fields.get(\"refresh_token\")\n )\n if credential and not foreign_refresh and await _answer_from_credential(flow, name, oauth, refresh_request):\n return\n flow.metadata[\"hyperbrane_token\"] = (name, refresh_request)\n return\n if credential:\n for answer in oauth.get(\"answers\", []):\n if url == _base(answer[\"url\"]):\n flow.response = http.Response.make(\n answer.get(\"status\", 200), json.dumps(answer[\"json\"]), {\"content-type\": \"application/json\"}\n )\n return\n for mode, swap in ((oauth, _swap_oauth), (api_key, _swap_api_key), (forced, None)):\n if mode and host in _hosts(mode):\n if swap:\n swap(flow.request, name, mode)\n _force(flow.request, mode.get(\"force\"))\n\n\ndef responseheaders(flow: http.HTTPFlow):\n # Only token responses are rewritten; everything else streams through as it arrives. mitmproxy\n # otherwise holds each response until its body is complete, so a model's streamed reply (SSE)\n # reached the dimension in one piece at the end of the turn.\n if not flow.metadata.get(\"hyperbrane_token\"):\n flow.response.stream = True\n\n\ndef response(flow: http.HTTPFlow):\n token = flow.metadata.get(\"hyperbrane_token\")\n if not token:\n return\n name, refresh_request = token\n definition = next((d for d in _definitions() if d[\"name\"] == name), None)\n oauth = (definition or {}).get(\"modes\", {}).get(\"oauth\")\n if not oauth:\n return\n body = _parse_form_or_json(flow.response.get_text(strict=False), \"json\")\n if flow.response.status_code == 200 and body.get(\"access_token\"):\n credential = _store_tokens(name, oauth, body, refresh_request)\n flow.response.text = json.dumps(_client_body(oauth, credential))\n for header in (oauth.get(\"strip\") or {}).get(\"headers\", []):\n flow.response.headers.pop(header, None)\n",
8
8
  };
@@ -4,6 +4,7 @@ import type { Sandbox } from "../sandbox/api.ts";
4
4
  import { type Portal } from "./api.ts";
5
5
  import type { GatewayStorage } from "./gatewayStorage.ts";
6
6
  import type { PortalStorage } from "./portalStorage.ts";
7
+ /** @sealed */
7
8
  export interface PortalService {
8
9
  createPortal(portal: Portal): Promise<Portal>;
9
10
  destroyPortal(portalName: string): Promise<void>;
@@ -38,6 +39,7 @@ export declare const OS_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
38
39
  * `image` is the gateway image: it carries the wiring script run by the privileged helper.
39
40
  */
40
41
  export declare function createPortalService(storage: PortalStorage, gateways: GatewayStorage, docker: DockerClient, image: ManagedImage, host: Sandbox): PortalService;
42
+ /** @sealed */
41
43
  export interface PortalWatcher {
42
44
  /** Restores every portal, then keeps restoring those of each hyperbrane container that starts. */
43
45
  start(): void;