@coreplane/switchboard 1.266.0 → 1.266.1

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 (36) hide show
  1. package/dist/assets/deploy/cloudflare/worker.ts +7 -1
  2. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +5 -1
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +48 -3
  4. package/dist/assets/deploy/cloudflare-resident/legacyCredentials.ts +43 -0
  5. package/dist/assets/deploy/cloudflare-resident/worker.ts +102 -141
  6. package/dist/assets/package-lock.json +3 -3
  7. package/dist/assets/package.json +1 -1
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/channels/githubDoorPaths.ts +36 -0
  10. package/dist/assets/src/core/coordinator/contract.ts +2 -0
  11. package/dist/assets/src/core/refusal.ts +1 -0
  12. package/dist/assets/src/core/runEvents.ts +1 -0
  13. package/dist/assets/src/core/runRecord.ts +41 -0
  14. package/dist/assets/src/execution/residentReadonly.ts +9 -8
  15. package/dist/assets/src/execution/residentSteps.ts +2 -1
  16. package/dist/assets/src/execution/seedPlan.ts +2 -2
  17. package/dist/assets/web/dist/.vite/manifest.json +49 -49
  18. package/dist/assets/web/dist/assets/{HomePage-B2Y-Fa9V.js → HomePage-BfpIrKDX.js} +1 -1
  19. package/dist/assets/web/dist/assets/{PendingTurnRow-DIC86uWA.js → PendingTurnRow-C6hroa00.js} +1 -1
  20. package/dist/assets/web/dist/assets/{PlanePage-CfrbMh8t.js → PlanePage-C2Cb1mkT.js} +1 -1
  21. package/dist/assets/web/dist/assets/{ResidentDetailPage-DRNwx2--.js → ResidentDetailPage-Bd9_Q87l.js} +1 -1
  22. package/dist/assets/web/dist/assets/{ResidentsIndexPage-EHKMtX69.js → ResidentsIndexPage-C8eS7hxp.js} +1 -1
  23. package/dist/assets/web/dist/assets/{RunFoldRow-CAdpX4Gn.js → RunFoldRow-DrfFauYJ.js} +1 -1
  24. package/dist/assets/web/dist/assets/{RunRoutePage-DppLhu79.js → RunRoutePage-B_eXfNAF.js} +3 -3
  25. package/dist/assets/web/dist/assets/{RunsIndexPage-C7hx5O1X.js → RunsIndexPage-BpQu36Dm.js} +1 -1
  26. package/dist/assets/web/dist/assets/{ScheduledPage-Dk2kMnYT.js → ScheduledPage-DGHcjcSt.js} +1 -1
  27. package/dist/assets/web/dist/assets/{SettingsPage-DAf6R383.js → SettingsPage-IfQ71QjN.js} +1 -1
  28. package/dist/assets/web/dist/assets/{SilentTurn-CPED_XCV.js → SilentTurn-D9ON15a8.js} +1 -1
  29. package/dist/assets/web/dist/assets/{StatusDot-IkfsHGXg.js → StatusDot-DyFjGg4V.js} +1 -1
  30. package/dist/assets/web/dist/assets/{Tooltip-CmQVfAl2.js → Tooltip-BvPkV4kZ.js} +1 -1
  31. package/dist/assets/web/dist/assets/{UnitRoutePage-BjfuZVWr.js → UnitRoutePage-BNe3nBIG.js} +1 -1
  32. package/dist/assets/web/dist/assets/{dist-Dg6HTzpQ.js → dist-v3Py3_QR.js} +1 -1
  33. package/dist/assets/web/dist/assets/{main-Bk1L-bNk.js → main-ks3kBojV.js} +2 -2
  34. package/dist/assets/web/dist/assets/{sseReplay-DGRDRLua.js → sseReplay-DuALaBPq.js} +1 -1
  35. package/dist/cli.js +1834 -641
  36. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { depotCiAuthorization, handleDepotCi } from "./depotCi.ts";
2
2
  import { DEPOT_CI_AUTHORIZATION_PATH, DEPOT_CI_PATH } from "../../src/core/depotCi.ts";
3
+ import { githubDoorEdgeRoute } from "../../src/channels/githubDoorPaths.ts";
3
4
  // Cloudflare Containers shim: runs the unchanged Switchboard image as a single
4
5
  // always-on container instance — the shape of any long-lived server on
5
6
  // Cloudflare Containers (singleton DO, cron keep-alive;
@@ -88,6 +89,7 @@ export interface Env {
88
89
  DEPOT_API_TOKEN?: string; // Worker-only organization credential: NEVER forwarded to any container
89
90
  DEPOT_CI_BRIDGE_TOKEN?: string; // bot → Worker, narrow repo-bound CI operations only
90
91
  PUBLIC_BASE_URL?: string; // live-view: base for /runs/<id>?t=… links on the status card
92
+ PUBLIC_GIT_BASE_URL?: string; // GitHub run-bearer door on a separate custom domain without dashboard Access
91
93
  ACCESS_TEAM_DOMAIN?: string; // live-view SSO gate: Cloudflare Access team domain (JWKS + iss)
92
94
  ACCESS_AUD?: string; // live-view SSO gate: Cloudflare Access application AUD tag
93
95
  DASHBOARD_TOKEN?: string; // dashboard auth `token` strategy: the bearer (the default env name; config may name another)
@@ -130,6 +132,7 @@ const FORWARDED_OPTIONAL = [
130
132
  "GITHUB_APP_PRIVATE_KEY",
131
133
  "DEPOT_CI_BRIDGE_TOKEN",
132
134
  "PUBLIC_BASE_URL",
135
+ "PUBLIC_GIT_BASE_URL",
133
136
  "ACCESS_TEAM_DOMAIN",
134
137
  "ACCESS_AUD",
135
138
  "DASHBOARD_TOKEN",
@@ -462,7 +465,10 @@ const withLength = (res: Response): Response => withKnownLength(res, (size) => n
462
465
 
463
466
  export default {
464
467
  async fetch(request: Request, env: Env): Promise<Response> {
465
- const pathname = new URL(request.url).pathname;
468
+ const requestUrl = new URL(request.url);
469
+ const pathname = requestUrl.pathname;
470
+ if (githubDoorEdgeRoute(requestUrl, request.method, env.PUBLIC_GIT_BASE_URL) === "refuse")
471
+ return new Response("not found", { status: 404 });
466
472
  // Permits can only be consumed through the Worker's fixed container
467
473
  // binding. The public edge must never proxy this callback path.
468
474
  if (pathname === DEPOT_CI_AUTHORIZATION_PATH) return new Response("not found", { status: 404 });
@@ -13,7 +13,10 @@
13
13
  // Cloudflare Access (SSO), and Access can only front a real zone domain —
14
14
  // leaving the workers.dev URL up would expose an un-SSO'd copy of /runs.
15
15
  "workers_dev": false,
16
- "routes": [{ "pattern": "{{hostname}}", "custom_domain": true }],
16
+ "routes": [
17
+ { "pattern": "{{hostname}}", "custom_domain": true },
18
+ { "pattern": "git.{{hostname}}", "custom_domain": true }
19
+ ],
17
20
  // Non-secret, deploy-constant config for the container. PUBLIC_BASE_URL: the
18
21
  // live-view link base — the dispatcher (in the container) reads
19
22
  // process.env.PUBLIC_BASE_URL to put the /runs/<id>?t=… link on the status
@@ -27,6 +30,7 @@
27
30
  // every dashboard request is denied.
28
31
  "vars": {
29
32
  "PUBLIC_BASE_URL": "{{urls.publicBaseUrl}}",
33
+ "PUBLIC_GIT_BASE_URL": "https://git.{{hostname}}",
30
34
  // {{#if access}}
31
35
  "ACCESS_TEAM_DOMAIN": "{{access.teamDomain}}",
32
36
  "ACCESS_AUD": "{{access.aud}}",
@@ -23,6 +23,7 @@ import {
23
23
  applyRetention,
24
24
  clampRetentionPolicy,
25
25
  isRunRecord,
26
+ isRecoveryEvidenceScope,
26
27
  isRunSession,
27
28
  isRunVisibilityFilter,
28
29
  normalizeStored,
@@ -4163,6 +4164,12 @@ export class RunHistoryDO extends DurableObject<Env> {
4163
4164
  * walked until the page fills; the kept set is the newest prefix of the
4164
4165
  * in-cutoff order, so the walk stops at the first row outside it.
4165
4166
  *
4167
+ * Recovery evidence additionally streams every retained summary through the
4168
+ * record parser before trusting identity filters: valid JSON can still be
4169
+ * unreadable evidence whose missing identity would hide a competing child.
4170
+ * This does not load the retained summaries into memory together or change
4171
+ * the relevant-row cap; ordinary list queries keep the fast path above.
4172
+ *
4166
4173
  * `visibleTo` — the caller's authorization predicate (authorization.md item
4167
4174
  * 6) — is compiled into the same WHERE clause (`visibilitySql`): its leaves
4168
4175
  * become `channel_id IN (…)`, `channel_visibility IN (…)`, `user_id = ?`,
@@ -4170,7 +4177,9 @@ export class RunHistoryDO extends DurableObject<Env> {
4170
4177
  * indexed filter on the page query, never a post-filter. `none` answers an
4171
4178
  * empty page without a query.
4172
4179
  */
4173
- async list(q: RunListOptions): Promise<{ items: RunListItem[]; nextBefore?: { finishedAt: number; id: string } }> {
4180
+ async list(
4181
+ q: RunListOptions,
4182
+ ): Promise<{ items: RunListItem[]; nextBefore?: { finishedAt: number; id: string }; evidenceComplete?: boolean }> {
4174
4183
  const now = systemClock();
4175
4184
  const limit = clampListLimit(q.limit);
4176
4185
  if (q.visibleTo?.kind === "none") return { items: [] };
@@ -4204,6 +4213,29 @@ export class RunHistoryDO extends DurableObject<Env> {
4204
4213
  where.push(`parent_run_id = ?`);
4205
4214
  params.push(q.parentRunId);
4206
4215
  }
4216
+ if (q.recoveryEvidence !== undefined) {
4217
+ // Identity comparisons cannot prove an unreadable record is unrelated.
4218
+ // Use the same parser as the result loop, not a weaker SQL shape check.
4219
+ for (const row of this.sql.exec<Pick<RunRow, "run_id" | "summary_json">>(
4220
+ `SELECT run_id, summary_json FROM runs WHERE finished_at >= ? ORDER BY finished_at DESC, run_id DESC`,
4221
+ cutoff,
4222
+ )) {
4223
+ if (kept !== null && !kept.has(row.run_id)) break; // only the retained newest-first prefix
4224
+ if (!parseSummary(row)) return { items: [], evidenceComplete: false };
4225
+ }
4226
+ const scope = q.recoveryEvidence;
4227
+ const unitKey = `${scope.instanceId}:${scope.unit}`;
4228
+ const prefix = `${unitKey}/`;
4229
+ // Literal prefix, not LIKE: instance ids can contain SQL wildcard chars.
4230
+ // Bare unit keys remain candidates, never proof of absence.
4231
+ where.push(`(CASE WHEN json_valid(summary_json) THEN (
4232
+ json_extract(summary_json, '$.parentInstanceId') = ? OR
4233
+ json_extract(summary_json, '$.idempotencyKey') = ? OR
4234
+ substr(json_extract(summary_json, '$.idempotencyKey'), 1, ?) = ? OR
4235
+ thread_key IN (${scope.threadKeys.map(() => "?").join(",")})
4236
+ ) ELSE 1 END)`);
4237
+ params.push(scope.instanceId, unitKey, prefix.length, prefix, ...scope.threadKeys);
4238
+ }
4207
4239
  if (q.pr !== undefined) {
4208
4240
  // `namesPullRequest` in SQL: the row's repository and the number it names.
4209
4241
  where.push(`repo = ?`, `pr_number = ?`);
@@ -4216,13 +4248,18 @@ export class RunHistoryDO extends DurableObject<Env> {
4216
4248
  // the walk stops early at the first evicted row) — never a full table load.
4217
4249
  const rows = this.sql.exec<RunRow>(`${select} LIMIT ?`, ...params, limit).toArray();
4218
4250
  const items: RunListItem[] = [];
4251
+ let malformed = false;
4219
4252
  for (const row of rows) {
4220
4253
  if (items.length >= limit) break;
4221
4254
  if (kept !== null && !kept.has(row.run_id)) break; // kept is a newest-first prefix: nothing older is kept either
4222
4255
  const summary = parseSummary(row);
4223
4256
  if (summary) items.push({ ...summary, bytes: row.bytes });
4257
+ else malformed = true;
4224
4258
  }
4225
- const out: { items: RunListItem[]; nextBefore?: { finishedAt: number; id: string } } = { items };
4259
+ const out: { items: RunListItem[]; nextBefore?: { finishedAt: number; id: string }; evidenceComplete?: boolean } = {
4260
+ items,
4261
+ };
4262
+ if (q.recoveryEvidence !== undefined) out.evidenceComplete = !malformed && rows.length < limit;
4226
4263
  if (items.length === limit) {
4227
4264
  const last = items[items.length - 1];
4228
4265
  out.nextBefore = { finishedAt: last.finishedAt, id: last.id };
@@ -4322,7 +4359,7 @@ function identityOfSummary(raw: string): { userName?: string; parentRunId?: stri
4322
4359
  }
4323
4360
  }
4324
4361
 
4325
- function parseSummary(row: RunRow): Omit<RunRecord, "events"> | null {
4362
+ function parseSummary(row: Pick<RunRow, "summary_json">): Omit<RunRecord, "events"> | null {
4326
4363
  try {
4327
4364
  const parsed: unknown = JSON.parse(row.summary_json);
4328
4365
  return isRunRecord({ ...(parsed as object), events: [] })
@@ -4492,6 +4529,14 @@ function parseRunList(body: unknown): Validated<{ storeKey: string; query: RunLi
4492
4529
  return invalid("pr must be { repo: owner/name, number: a positive integer }");
4493
4530
  query.pr = { repo: pr.repo, number: pr.number };
4494
4531
  }
4532
+ if (b.recoveryEvidence !== undefined) {
4533
+ if (!isRecoveryEvidenceScope(b.recoveryEvidence))
4534
+ return invalid("recoveryEvidence must name an instance, unit and original threads");
4535
+ // No paged or narrowed response can attest complete recovery evidence.
4536
+ if (Object.keys(query).some((field) => field !== "limit") || b.visibleTo !== undefined)
4537
+ return invalid("recoveryEvidence cannot be combined with list filters or cursors");
4538
+ query.recoveryEvidence = b.recoveryEvidence;
4539
+ }
4495
4540
  if (b.visibleTo !== undefined) {
4496
4541
  // A malformed filter is a 400, never "all": the bot degrades to live rows
4497
4542
  // rather than the DO widening what an actor may see.
@@ -0,0 +1,43 @@
1
+ /** Root-side cleanup for credentials left by an earlier resident release.
2
+ * Open every directory component without following symlinks, then unlink
3
+ * only the named file relative to its pinned directory descriptor. A model
4
+ * owns the old tree and stage directory, so a pathname-based root `rm` can
5
+ * otherwise traverse a replacement symlink outside that tree. */
6
+ export const LEGACY_CREDENTIAL_SCRUB = `import os, sys
7
+
8
+ flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
9
+
10
+ def open_dir(path):
11
+ fd = os.open('/', flags)
12
+ try:
13
+ for part in path.strip('/').split('/'):
14
+ if not part:
15
+ continue
16
+ child = os.open(part, flags, dir_fd=fd)
17
+ os.close(fd)
18
+ fd = child
19
+ return fd
20
+ except BaseException:
21
+ os.close(fd)
22
+ raise
23
+
24
+ def remove(parent, name):
25
+ try:
26
+ fd = open_dir(parent)
27
+ except FileNotFoundError:
28
+ return
29
+ try:
30
+ try:
31
+ os.unlink(name, dir_fd=fd)
32
+ except FileNotFoundError:
33
+ pass
34
+ finally:
35
+ os.close(fd)
36
+
37
+ remove(sys.argv[1], 'github-credentials')
38
+ remove(sys.argv[2], 'cred')
39
+ `;
40
+
41
+ export function legacyCredentialScrubCommand(worktreePath: string, stageDir: string): string[] {
42
+ return ["python3", "-c", LEGACY_CREDENTIAL_SCRUB, `${worktreePath}/.git`, stageDir];
43
+ }