@moondeck/station 0.0.0-stage → 1.0.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 (325) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +131 -4
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Lane `xai-oauth` — Grok subscription sign-ins (`grok login --device-auth`).
3
+ *
4
+ * `auth.json` is keyed by `<issuer>::<client-id>`; each entry carries
5
+ * `key` (the bearer the CLI sends), `refresh_token`, `expires_at` (ISO),
6
+ * `oidc_issuer` and `oidc_client_id`. Refresh is a standard OIDC
7
+ * `grant_type=refresh_token` against `https://auth.x.ai/oauth2/token`
8
+ * (confirmed from the issuer's discovery document, 2026-09-09; refresh_token
9
+ * is among its supported grants).
10
+ *
11
+ * ASSUMPTION, recorded until observed on a live refresh: `key` is the OIDC
12
+ * access token, so a refresh replaces it with `access_token`. The lane never
13
+ * writes on an answer that lacks `access_token`, so a wrong assumption costs
14
+ * a failed refresh (retried, then the reactive path), never a corrupted
15
+ * sign-in.
16
+ */
17
+
18
+ import {
19
+ BROKER_REFRESH_PLACEHOLDER,
20
+ isTerminalOAuthError,
21
+ parseObjectFile,
22
+ type CredentialFiles,
23
+ type CredentialHeader,
24
+ type LaneHttp,
25
+ type RefreshOutcome,
26
+ type TokenLane,
27
+ } from "./types";
28
+ import { brokerErrorKind, CREDENTIAL_LANE_SCHEDULES } from "../../src/cloud-credentials";
29
+
30
+ export const XAI_ISSUER = "https://auth.x.ai";
31
+ export const XAI_TOKEN_ENDPOINT = `${XAI_ISSUER}/oauth2/token`;
32
+ const HOUR_MS = 60 * 60 * 1000;
33
+
34
+ interface Entry {
35
+ key: string;
36
+ doc: Record<string, unknown>;
37
+ }
38
+
39
+ function entriesOf(files: CredentialFiles): { doc: Record<string, unknown>; entries: Entry[] } | null {
40
+ const doc = parseObjectFile(files["auth.json"]);
41
+ if (!doc) return null;
42
+ const entries: Entry[] = [];
43
+ for (const [key, value] of Object.entries(doc)) {
44
+ if (value && typeof value === "object" && !Array.isArray(value)) {
45
+ entries.push({ key, doc: value as Record<string, unknown> });
46
+ }
47
+ }
48
+ return { doc, entries };
49
+ }
50
+
51
+ function expiryOf(entry: Record<string, unknown>): number | null {
52
+ const raw = entry.expires_at;
53
+ if (typeof raw !== "string") return null;
54
+ const ms = Date.parse(raw);
55
+ return Number.isFinite(ms) ? ms : null;
56
+ }
57
+
58
+ export const xaiOAuthLane: TokenLane = {
59
+ id: "xai-oauth",
60
+ endpoints: [XAI_TOKEN_ENDPOINT],
61
+ // Observed 2026-09-09: the token lives SIX hours (create_time → expires_at).
62
+ // Refresh with three hours left — the hourly cycle then renews it at about
63
+ // four hours of age — and every four hours when expiry cannot be read.
64
+ // ADR-154 §7: the cloud names a revision on the same schedule.
65
+ schedule: CREDENTIAL_LANE_SCHEDULES["xai-oauth"]!,
66
+ reactiveSignals: [/invalid_grant/i, /token (?:has )?expired/i, /log ?in again/i, /unauthorized/i],
67
+
68
+ matches(header: CredentialHeader, files: CredentialFiles): boolean {
69
+ if (header.kind !== "grok" || header.authKind !== "config-dir") return false;
70
+ const parsed = entriesOf(files);
71
+ return (
72
+ parsed !== null && parsed.entries.some((e) => typeof e.doc.refresh_token === "string")
73
+ );
74
+ },
75
+
76
+ expiresAt(files: CredentialFiles): number | null {
77
+ const parsed = entriesOf(files);
78
+ if (!parsed) return null;
79
+ const known = parsed.entries.map((e) => expiryOf(e.doc)).filter((v): v is number => v !== null);
80
+ return known.length ? Math.min(...known) : null;
81
+ },
82
+
83
+ brokeredCopy(files: CredentialFiles): CredentialFiles {
84
+ const parsed = entriesOf(files);
85
+ if (!parsed) return files;
86
+ let changed = false;
87
+ for (const entry of parsed.entries) {
88
+ if (
89
+ typeof entry.doc.refresh_token === "string" &&
90
+ entry.doc.refresh_token !== BROKER_REFRESH_PLACEHOLDER
91
+ ) {
92
+ entry.doc.refresh_token = BROKER_REFRESH_PLACEHOLDER;
93
+ changed = true;
94
+ }
95
+ }
96
+ return changed ? { ...files, "auth.json": `${JSON.stringify(parsed.doc, null, 2)}\n` } : files;
97
+ },
98
+
99
+ async refresh(files: CredentialFiles, http: LaneHttp, now: number): Promise<RefreshOutcome> {
100
+ const parsed = entriesOf(files);
101
+ if (!parsed) return { ok: false, terminal: true, reason: "auth.json is not readable" };
102
+ let refreshed = 0;
103
+ let expiresAt: number | null = null;
104
+ for (const entry of parsed.entries) {
105
+ const current = entry.doc.refresh_token;
106
+ if (typeof current !== "string" || current === BROKER_REFRESH_PLACEHOLDER) continue;
107
+ const issuer = typeof entry.doc.oidc_issuer === "string" ? entry.doc.oidc_issuer : XAI_ISSUER;
108
+ if (issuer.replace(/\/$/, "") !== XAI_ISSUER) {
109
+ return { ok: false, terminal: true, reason: "unexpected issuer" };
110
+ }
111
+ const clientId = entry.doc.oidc_client_id;
112
+ if (typeof clientId !== "string" || !clientId) {
113
+ return { ok: false, terminal: true, reason: "entry has no client id" };
114
+ }
115
+ let res;
116
+ try {
117
+ res = await http.postForm(XAI_TOKEN_ENDPOINT, {
118
+ grant_type: "refresh_token",
119
+ refresh_token: current,
120
+ client_id: clientId,
121
+ });
122
+ } catch (error) {
123
+ return {
124
+ ok: false,
125
+ terminal: false,
126
+ // A transport error can quote the request (and therefore the refresh
127
+ // token) in its message. Only its bounded kind may cross the lane.
128
+ reason: brokerErrorKind(error),
129
+ };
130
+ }
131
+ if (res.status < 200 || res.status >= 300) {
132
+ return {
133
+ ok: false,
134
+ terminal: isTerminalOAuthError(res.status, res.json),
135
+ reason: `HTTP ${res.status}`,
136
+ };
137
+ }
138
+ const body =
139
+ res.json && typeof res.json === "object" && !Array.isArray(res.json)
140
+ ? (res.json as Record<string, unknown>)
141
+ : null;
142
+ const access = body?.access_token;
143
+ if (typeof access !== "string" || !access) {
144
+ return { ok: false, terminal: false, reason: "response missing access_token" };
145
+ }
146
+ entry.doc.key = access;
147
+ if (typeof body?.refresh_token === "string" && body.refresh_token) {
148
+ entry.doc.refresh_token = body.refresh_token;
149
+ }
150
+ const expiresIn = body?.expires_in;
151
+ if (typeof expiresIn === "number" && Number.isFinite(expiresIn)) {
152
+ const at = now + expiresIn * 1000;
153
+ entry.doc.expires_at = new Date(at).toISOString();
154
+ expiresAt = expiresAt === null ? at : Math.min(expiresAt, at);
155
+ }
156
+ refreshed += 1;
157
+ }
158
+ if (refreshed === 0) return { ok: false, terminal: true, reason: "no refresh token to use" };
159
+ return {
160
+ ok: true,
161
+ files: { ...files, "auth.json": `${JSON.stringify(parsed.doc, null, 2)}\n` },
162
+ expiresAt,
163
+ };
164
+ },
165
+ };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The shared channel transcript (ADR-027-adjacent). Agents have private,
3
+ * isolated conversations — each only hears what it's addressed. So they can opt
4
+ * into cross-agent awareness, the host maintains a plain-text log of every chat
5
+ * message at `<workspace>/.moondeck/chat.md` (mounted at /workspace), which any agent
6
+ * can read on demand. Secretary mode adds a frontstage projection at
7
+ * `chat-front.md`; the cloud decides which projection(s) a row belongs to and
8
+ * sends an explicit target list. Conversational rows (including dispatch
9
+ * instructions) only — no tool-call execution traces.
10
+ */
11
+
12
+ import { mkdirSync } from "node:fs";
13
+
14
+ import { taskWorkspaceDir } from "./env";
15
+ import { rewriteAttachmentRefs } from "./orchestrator";
16
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
17
+ import { appendContainedFileSync, makeContainedDirectoriesSync } from "./task-environment/workspace-files";
18
+ import type { TranscriptTarget } from "../src/protocol";
19
+
20
+ /** Container path agents are pointed at. */
21
+ export const CONTAINER_TRANSCRIPT_PATH = "/workspace/.moondeck/chat.md";
22
+ export const CONTAINER_FRONT_TRANSCRIPT_PATH =
23
+ "/workspace/.moondeck/chat-front.md";
24
+
25
+ const TARGET_FILENAME: Record<TranscriptTarget, string> = {
26
+ chat: "chat.md",
27
+ "chat-front": "chat-front.md",
28
+ };
29
+
30
+ function transcriptEntry(author: string, text: string): string | null {
31
+ // Rewrite cloud attachment URLs to the in-container path so an agent reading
32
+ // the transcript can open referenced files directly.
33
+ const body = rewriteAttachmentRefs(text).trim();
34
+ if (!body) return null;
35
+ return `## ${author}\n\n${body}\n\n`;
36
+ }
37
+
38
+ export function appendTranscript(
39
+ taskId: string,
40
+ author: string,
41
+ text: string,
42
+ targets: TranscriptTarget[],
43
+ ): void {
44
+ const entry = transcriptEntry(author, text);
45
+ if (entry === null) return;
46
+ // Into the task's own .moondeck, never through a link the task put there
47
+ // (#cb3j0z). The workspace root is the runner's own path; .moondeck, directly
48
+ // below it, is made if gone without following a link.
49
+ const workspace = taskWorkspaceDir(taskId);
50
+ mkdirSync(workspace, { recursive: true });
51
+ makeContainedDirectoriesSync(workspace, [".moondeck"]);
52
+ for (const target of new Set(targets)) {
53
+ appendContainedFileSync(workspace, [".moondeck", TARGET_FILENAME[target]], Buffer.from(entry));
54
+ }
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // ADR-121: environment-side transcript for machine-backed tasks. The
59
+ // workspace lives in the machine's world, so the append travels over the
60
+ // environment transport (entry via stdin). A per-task promise chain
61
+ // preserves message order the way the sync host-FS path did implicitly.
62
+ // ---------------------------------------------------------------------------
63
+
64
+ const environmentAppendChains = new Map<string, Promise<void>>();
65
+
66
+ async function appendOneViaEnvironment(
67
+ environment: TaskEnvironmentHandle<unknown>,
68
+ target: TranscriptTarget,
69
+ entry: string,
70
+ ): Promise<void> {
71
+ const path = `${environment.descriptor.workspacePath}/.moondeck/${TARGET_FILENAME[target]}`;
72
+ const proc = await environment.spawn({
73
+ projectEnvProfile: "maintenance",
74
+ argv: [
75
+ "/bin/sh",
76
+ "-c",
77
+ 'mkdir -p "$(dirname "$1")" && cat >> "$1"',
78
+ "append",
79
+ path,
80
+ ],
81
+ inheritEnv: [],
82
+ env: {},
83
+ timeoutMs: 30_000,
84
+ maxOutputBytes: 8 * 1024,
85
+ });
86
+ const drains = [
87
+ (async () => {
88
+ for await (const chunk of proc.stdout) void chunk;
89
+ })(),
90
+ (async () => {
91
+ for await (const chunk of proc.stderr) void chunk;
92
+ })(),
93
+ ];
94
+ await proc.write(Buffer.from(entry));
95
+ await proc.closeInput();
96
+ const exit = await proc.completion;
97
+ await Promise.allSettled(drains);
98
+ if (exit.exitCode !== 0) {
99
+ throw new Error(`environment transcript append exited ${exit.exitCode}`);
100
+ }
101
+ }
102
+
103
+ export function appendTranscriptViaEnvironment(
104
+ taskId: string,
105
+ environment: TaskEnvironmentHandle<unknown>,
106
+ author: string,
107
+ text: string,
108
+ targets: TranscriptTarget[],
109
+ ): Promise<void> {
110
+ const entry = transcriptEntry(author, text);
111
+ if (entry === null) return Promise.resolve();
112
+ const previous = environmentAppendChains.get(taskId) ?? Promise.resolve();
113
+ const next = previous
114
+ .catch(() => {})
115
+ .then(async () => {
116
+ for (const target of new Set(targets)) {
117
+ await appendOneViaEnvironment(environment, target, entry);
118
+ }
119
+ });
120
+ environmentAppendChains.set(taskId, next);
121
+ // Bound the map: forget the chain once it settles as the latest entry.
122
+ void next.finally(() => {
123
+ if (environmentAppendChains.get(taskId) === next) {
124
+ environmentAppendChains.delete(taskId);
125
+ }
126
+ });
127
+ return next;
128
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Tunnel upstreams, plus the frames that arrive before an upstream exists.
3
+ *
4
+ * `tunnel.open` is handled ASYNCHRONOUSLY on the host: resolving a target can
5
+ * await the task lifecycle lock and, for an ad-hoc preview, `docker run` of a
6
+ * sidecar (ADR-043). The frames that follow it — `tunnel.data`,
7
+ * `tunnel.requestEnd`, `tunnel.close` — are handled synchronously off the same
8
+ * WebSocket, and the cloud emits `requestEnd` for a bodyless GET on the tick
9
+ * after `open`. So they routinely land while the open is still resolving.
10
+ *
11
+ * Looking the tunnel up with `tunnels.get(id)?.` silently dropped them, and a
12
+ * dropped `requestEnd` is unrecoverable rather than merely late. The HTTP open
13
+ * deliberately leaves its `ClientRequest` unended so request bodies can stream,
14
+ * and node writes no header until `write`/`end` — so the upstream socket
15
+ * CONNECTS and then receives nothing at all. On the wire that reads as an
16
+ * established TCP connection carrying zero bytes, an upstream that never saw a
17
+ * request, and a flat 30s `504 host did not respond in time` from the cloud's
18
+ * ack deadline: four symptoms that each point somewhere different, none of them
19
+ * at the open that never finished.
20
+ *
21
+ * So ops for an opening tunnel are QUEUED and replayed on `register`. The
22
+ * queue is bounded by the request itself — the cloud stops sending once its
23
+ * ack deadline fires — and is dropped by `abandon` when an open fails, so a
24
+ * tunnel that never opens leaves nothing behind.
25
+ */
26
+
27
+ export interface TunnelUpstream {
28
+ write(chunk: Buffer): boolean;
29
+ end(): void;
30
+ destroy(): void;
31
+ }
32
+
33
+ type TunnelOp = (upstream: TunnelUpstream) => void;
34
+ type TunnelRelease = () => Promise<void>;
35
+
36
+ interface OpenTunnel {
37
+ readonly upstream: TunnelUpstream;
38
+ readonly release: TunnelRelease;
39
+ }
40
+
41
+ interface OpeningTunnel {
42
+ readonly ops: TunnelOp[];
43
+ readonly settled: Promise<void>;
44
+ finish(): void;
45
+ aborted: boolean;
46
+ }
47
+
48
+ export class TunnelRegistry {
49
+ private readonly open = new Map<string, OpenTunnel>();
50
+ /** tunnelId -> ops that arrived while its `tunnel.open` was still resolving. */
51
+ private readonly opening = new Map<string, OpeningTunnel>();
52
+ /** Async provider teardown is joined by connection replacement/shutdown. */
53
+ private readonly pendingReleases = new Set<Promise<void>>();
54
+
55
+ /**
56
+ * Mark a tunnel as opening. MUST be called synchronously when `tunnel.open`
57
+ * is received — before the first `await` of target resolution — or the frames
58
+ * that follow have nothing to queue against and are dropped, which is the
59
+ * whole bug this class exists for.
60
+ */
61
+ markOpening(tunnelId: string): void {
62
+ const previous = this.opening.get(tunnelId);
63
+ if (previous) {
64
+ previous.aborted = true;
65
+ previous.finish();
66
+ }
67
+ let finish!: () => void;
68
+ const settled = new Promise<void>((resolve) => {
69
+ finish = resolve;
70
+ });
71
+ this.opening.set(tunnelId, {
72
+ ops: [],
73
+ settled,
74
+ finish,
75
+ aborted: false,
76
+ });
77
+ }
78
+
79
+ /** Target resolved: publish the upstream and replay what arrived meanwhile.
80
+ * Returns whether this upstream now owns the tunnel — false means the open
81
+ * was canceled during resolution and the upstream was destroyed, so the
82
+ * caller must not write to it (a stale generation must never emit bytes). */
83
+ register(
84
+ tunnelId: string,
85
+ upstream: TunnelUpstream,
86
+ release: TunnelRelease = async () => {},
87
+ ): boolean {
88
+ const pending = this.opening.get(tunnelId);
89
+ this.opening.delete(tunnelId);
90
+ pending?.finish();
91
+ const releaseOnce = onceAsync(release);
92
+ if (pending?.aborted) {
93
+ upstream.destroy();
94
+ this.trackRelease(releaseOnce());
95
+ return false;
96
+ }
97
+ this.open.set(tunnelId, { upstream, release: releaseOnce });
98
+ if (!pending) return true;
99
+ for (const op of pending.ops) op(upstream);
100
+ return true;
101
+ }
102
+
103
+ /** Open failed (no target, or it errored) — nothing will ever service these. */
104
+ abandon(tunnelId: string): void {
105
+ this.opening.get(tunnelId)?.finish();
106
+ this.opening.delete(tunnelId);
107
+ }
108
+
109
+ get(tunnelId: string): TunnelUpstream | undefined {
110
+ return this.open.get(tunnelId)?.upstream;
111
+ }
112
+
113
+ delete(tunnelId: string): void {
114
+ const current = this.open.get(tunnelId);
115
+ this.open.delete(tunnelId);
116
+ if (current) this.trackRelease(current.release());
117
+ }
118
+
119
+ /**
120
+ * Run `op` against the upstream now, or queue it if the tunnel is still
121
+ * opening. A tunnel this registry has never heard of is dropped, exactly as
122
+ * the old `?.` did — that is a frame for a tunnel already torn down, not a
123
+ * race.
124
+ */
125
+ apply(tunnelId: string, op: TunnelOp): void {
126
+ const current = this.open.get(tunnelId);
127
+ if (current) {
128
+ op(current.upstream);
129
+ return;
130
+ }
131
+ const pending = this.opening.get(tunnelId);
132
+ if (pending && !pending.aborted) pending.ops.push(op);
133
+ }
134
+
135
+ /**
136
+ * Tear the tunnel down now, or as soon as it opens. Queued ops are discarded
137
+ * rather than replayed first: they exist to be written to an upstream that is
138
+ * about to be destroyed, and a `write` after `destroy` is at best wasted and
139
+ * at worst an unhandled error on a socket nobody is listening to any more.
140
+ */
141
+ abort(tunnelId: string): void {
142
+ const current = this.open.get(tunnelId);
143
+ this.open.delete(tunnelId);
144
+ if (current) {
145
+ current.upstream.destroy();
146
+ this.trackRelease(current.release());
147
+ return;
148
+ }
149
+ const pending = this.opening.get(tunnelId);
150
+ if (pending) {
151
+ pending.ops.length = 0;
152
+ pending.aborted = true;
153
+ }
154
+ }
155
+
156
+ /** Destroy every upstream from one bridge generation and wait until opening
157
+ * targets have either failed or registered and their provider resources are
158
+ * released. No SSH forward may outlive the WebSocket generation that owns
159
+ * it — but reconnect must not be hostage to one wedged open, so the join is
160
+ * bounded: entries are already marked aborted, their eventual register()
161
+ * destroys the upstream and releases provider resources, and the releases
162
+ * stay tracked for the next generation's join. */
163
+ async abortAll(timeoutMs = 15_000): Promise<void> {
164
+ for (const pending of this.opening.values()) {
165
+ pending.ops.length = 0;
166
+ pending.aborted = true;
167
+ }
168
+ for (const [tunnelId, current] of this.open) {
169
+ this.open.delete(tunnelId);
170
+ current.upstream.destroy();
171
+ this.trackRelease(current.release());
172
+ }
173
+ let timer: NodeJS.Timeout | undefined;
174
+ const expired = new Promise<"timeout">((resolve) => {
175
+ timer = setTimeout(() => resolve("timeout"), timeoutMs);
176
+ timer.unref?.();
177
+ });
178
+ try {
179
+ while (this.opening.size > 0 || this.pendingReleases.size > 0) {
180
+ const opening = [...this.opening.values()].map((entry) => entry.settled);
181
+ const outcome = await Promise.race([
182
+ Promise.all([...opening, ...this.pendingReleases]).then(
183
+ () => "settled" as const,
184
+ ),
185
+ expired,
186
+ ]);
187
+ if (outcome === "timeout") {
188
+ console.warn(
189
+ `[tunnel] abortAll: ${this.opening.size} opening and ` +
190
+ `${this.pendingReleases.size} releasing tunnels did not settle ` +
191
+ `within ${timeoutMs}ms; continuing while they drain`,
192
+ );
193
+ return;
194
+ }
195
+ }
196
+ } finally {
197
+ clearTimeout(timer);
198
+ }
199
+ }
200
+
201
+ private trackRelease(release: Promise<void>): void {
202
+ // A rejected provider release must neither crash the process (a bare
203
+ // `.finally()` chain rethrows with no handler attached) nor poison the
204
+ // abortAll() join above — cleanup failure is logged, never fatal.
205
+ const settled = release.catch((error: unknown) => {
206
+ console.warn(
207
+ `[tunnel] upstream release failed: ${
208
+ error instanceof Error ? error.message : String(error)
209
+ }`,
210
+ );
211
+ });
212
+ this.pendingReleases.add(settled);
213
+ void settled.finally(() => this.pendingReleases.delete(settled));
214
+ }
215
+ }
216
+
217
+ function onceAsync(operation: TunnelRelease): TunnelRelease {
218
+ let result: Promise<void> | null = null;
219
+ return () => (result ??= operation());
220
+ }