create-egregore 0.21.0-runtime-mvp.21 → 0.21.0-runtime-mvp.23

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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.21.0-runtime-mvp.21
2
+ // egregore-launcher-version: 0.21.0-runtime-mvp.23
3
3
  //
4
4
  // egregore — launch and browse Egregore instances from your terminal.
5
5
  // Installed by create-egregore. Reads ~/.egregore/instances.json.
@@ -35,7 +35,7 @@ const crypto = require("node:crypto");
35
35
  const { spawnSync, spawn } = require("node:child_process");
36
36
  const readline = require("node:readline");
37
37
 
38
- const VERSION = "0.21.0-runtime-mvp.21";
38
+ const VERSION = "0.21.0-runtime-mvp.23";
39
39
  const REGISTRY = path.join(os.homedir(), ".egregore", "instances.json");
40
40
  const DEFAULT_FILE = path.join(os.homedir(), ".egregore", "default");
41
41
  const UPDATE_CACHE_FILE = path.join(os.homedir(), ".egregore", "update-check.json");
@@ -285,7 +285,23 @@ function readRegistry() {
285
285
  if (key.includes("/.claude/worktrees/")) continue;
286
286
  if (seen.has(key)) continue;
287
287
  seen.add(key);
288
- result.push(e);
288
+ // The checkout config is the canonical identity for a path. Older
289
+ // installers could overwrite registry labels when two organizations
290
+ // used the same repository/folder name, producing hybrid rows such as
291
+ // another org's slug beside the Curve Labs checkout.
292
+ let canonical = e;
293
+ try {
294
+ const config = JSON.parse(
295
+ fs.readFileSync(path.join(key, "egregore.json"), "utf-8")
296
+ );
297
+ canonical = {
298
+ ...e,
299
+ slug: config.slug || e.slug,
300
+ name: config.org_name || e.name,
301
+ };
302
+ if (config.org_id) canonical.org_id = config.org_id;
303
+ } catch {}
304
+ result.push(canonical);
289
305
  }
290
306
  return result;
291
307
  } catch {
@@ -2499,20 +2515,33 @@ function formatTelemetryLines(status) {
2499
2515
  function upgradeStatus(inst, { fetch = false } = {}) {
2500
2516
  const args = ["status"]; if (fetch) args.push("--fetch");
2501
2517
  const r = runUpgrade(inst, args);
2502
- if (!r || r.status !== 0 || !r.stdout) return null;
2503
- try { return JSON.parse(r.stdout); } catch { return null; }
2518
+ if (!r || r.status !== 0 || !r.stdout) {
2519
+ const detail = String((r && (r.stderr || r.stdout)) || "Runtime status probe failed")
2520
+ .trim().split("\n")[0];
2521
+ return { status: "unavailable", failure: { message: detail } };
2522
+ }
2523
+ try { return JSON.parse(r.stdout); } catch {
2524
+ return { status: "unavailable", failure: { message: "Runtime status returned an unreadable response" } };
2525
+ }
2504
2526
  }
2505
2527
 
2506
2528
  function upgradeStatusAsync(inst, cb) {
2507
2529
  const bridge = resolveUpgradeBridge(inst);
2508
- if (!bridge) return cb(null);
2509
- let buf = "";
2530
+ if (!bridge) return cb({ status: "unavailable", failure: { message: "No Runtime update engine is installed" } });
2531
+ let buf = ""; let err = "";
2510
2532
  try {
2511
- const child = spawn("bash", [bridge.script, "status"], { cwd: inst.path, stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, ...bridge.env } });
2533
+ const child = spawn("bash", [bridge.script, "status"], { cwd: inst.path, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...bridge.env } });
2512
2534
  child.stdout.on("data", (d) => { buf += d; });
2513
- child.on("close", () => { try { cb(JSON.parse(buf)); } catch { cb(null); } });
2514
- child.on("error", () => cb(null));
2515
- } catch { cb(null); }
2535
+ child.stderr.on("data", (d) => { err += d; });
2536
+ child.on("close", (code) => {
2537
+ if (code === 0) {
2538
+ try { cb(JSON.parse(buf)); return; } catch {}
2539
+ }
2540
+ const detail = String(err || buf || "Runtime status probe failed").trim().split("\n")[0];
2541
+ cb({ status: "unavailable", failure: { message: detail } });
2542
+ });
2543
+ child.on("error", (error) => cb({ status: "unavailable", failure: { message: error.message } }));
2544
+ } catch (error) { cb({ status: "unavailable", failure: { message: error.message } }); }
2516
2545
  }
2517
2546
 
2518
2547
  // Local retrieval health for the settings surface. Package-update state,
@@ -2632,6 +2661,7 @@ const UPGRADE_STATUS_LABEL = {
2632
2661
  active: "active",
2633
2662
  cancelled: "cancelled",
2634
2663
  "rolled-back": "rolled back",
2664
+ unavailable: "unavailable",
2635
2665
  };
2636
2666
 
2637
2667
  // Settings-row truth for the Runtime update line. The row must say outright
@@ -2645,6 +2675,7 @@ function runtimeUpdateRowValue(st, candidate) {
2645
2675
  if (candidate && candidate !== v) return `${v} active ${YELLOW}· update available ${candidate}${RESET}`;
2646
2676
  return `${GREEN}${v} active${RESET}${candidate ? `${DIM} · up to date${RESET}` : ""}`;
2647
2677
  }
2678
+ if (st.status === "unavailable") return `${YELLOW}update unavailable${RESET}`;
2648
2679
  if (st.status === "ready-to-activate") return `${GREEN}${upgradeSummary(st)}${RESET}`;
2649
2680
  if (st.status === "failed" || st.status === "interrupted") return `${YELLOW}${upgradeSummary(st)}${RESET}`;
2650
2681
  if (st.status === "preparing" || st.status === "pending") return `${DIM}${upgradeSummary(st)}${RESET}`;
@@ -2685,7 +2716,7 @@ function formatUpgradeLines(inst, st, { candidate = "", retrieval = null } = {})
2685
2716
  if (d.candidate_digest) lines.push(`Package digest: sha256 ${String(d.candidate_digest).slice(0, 16)}…`);
2686
2717
  // A recorded failure only needs attention while the transaction is stuck
2687
2718
  // on it; once a later prepare succeeds the line would contradict "ready".
2688
- if (d.failure && d.failure.message && (d.status === "failed" || d.status === "interrupted")) {
2719
+ if (d.failure && d.failure.message && (["failed", "interrupted", "unavailable"].includes(d.status))) {
2689
2720
  lines.push(`Attention: ${d.failure.message}`);
2690
2721
  }
2691
2722
  if (d.rollback_version && d.status !== "none") lines.push(`Rollback target: ${d.rollback_version}`);
@@ -2760,6 +2791,7 @@ async function cmdSettingsRuntime(inst, args) {
2760
2791
  const st = upgradeStatus(inst, { fetch: fetchFlag });
2761
2792
  if (st === null) { process.stderr.write("Runtime update state is unavailable for this egregore.\n"); process.exitCode = 1; return; }
2762
2793
  emit(st);
2794
+ if (st.status === "unavailable") process.exitCode = 1;
2763
2795
  if (action === "review" || (st.status !== "active" && st.status !== "none")) {
2764
2796
  process.stdout.write("\nWhat stays:\n");
2765
2797
  for (const s of UPGRADE_STAYS) process.stdout.write(` · ${s}\n`);
package/lib/setup.js CHANGED
@@ -219,17 +219,37 @@ function slugifyName(name) {
219
219
  *
220
220
  * Most Egregore forks are named "egregore", so deriving the target only from
221
221
  * the fork basename makes different organizations collide in the same parent
222
- * directory. If that default path is already a Git checkout with another
223
- * origin, leave it untouched and use egregore-{org-slug}. The fallback must
224
- * also be absent or point at the requested fork; otherwise stop rather than
225
- * guessing at another path.
222
+ * directory. If that default path already belongs to another configured org
223
+ * or Git origin, leave it untouched and use egregore-{org-slug}. The fallback
224
+ * must also be absent or belong to the requested org and fork; otherwise stop
225
+ * rather than guessing at another path.
226
226
  */
227
- function resolveEgregoreInstallDir(base, forkUrl, slug) {
227
+ function configuredIdentityConflicts(dir, slug, orgId = "") {
228
+ let config;
229
+ try {
230
+ config = JSON.parse(fs.readFileSync(path.join(dir, "egregore.json"), "utf-8"));
231
+ } catch {
232
+ return false;
233
+ }
234
+ const existingSlug = slugifyName(config.slug || "");
235
+ const requestedSlug = slugifyName(slug || "");
236
+ if (existingSlug && requestedSlug && existingSlug !== requestedSlug) return true;
237
+
238
+ const stable = (value) => typeof value === "string" && value && !value.startsWith("legacy-org:");
239
+ return stable(config.org_id) && stable(orgId) && config.org_id !== orgId;
240
+ }
241
+
242
+ function installTargetMatches(dir, forkUrl, slug, orgId = "") {
243
+ if (configuredIdentityConflicts(dir, slug, orgId)) return false;
244
+ const gitPath = path.join(dir, ".git");
245
+ return !fs.existsSync(gitPath) || gitRemoteMatches(dir, forkUrl);
246
+ }
247
+
248
+ function resolveEgregoreInstallDir(base, forkUrl, slug, orgId = "") {
228
249
  const forkDirName = forkUrl.split("/").pop().replace(/\.git$/, "");
229
250
  const requestedDir = path.join(base, forkDirName);
230
- const requestedGitPath = path.join(requestedDir, ".git");
231
251
 
232
- if (!fs.existsSync(requestedGitPath) || gitRemoteMatches(requestedDir, forkUrl)) {
252
+ if (installTargetMatches(requestedDir, forkUrl, slug, orgId)) {
233
253
  return { egregoreDir: requestedDir, redirectedFrom: null };
234
254
  }
235
255
 
@@ -237,15 +257,14 @@ function resolveEgregoreInstallDir(base, forkUrl, slug) {
237
257
  const fallbackDir = path.join(base, `egregore-${safeSlug}`);
238
258
  if (path.resolve(fallbackDir) === path.resolve(requestedDir)) {
239
259
  throw new Error(
240
- `Refusing to install into ${requestedDir}: its Git origin does not match ${forkUrl}`
260
+ `Refusing to install into ${requestedDir}: it belongs to another Egregore or Git origin`
241
261
  );
242
262
  }
243
263
 
244
264
  if (fs.existsSync(fallbackDir)) {
245
- const fallbackGitPath = path.join(fallbackDir, ".git");
246
- if (!fs.existsSync(fallbackGitPath) || !gitRemoteMatches(fallbackDir, forkUrl)) {
265
+ if (!installTargetMatches(fallbackDir, forkUrl, slug, orgId)) {
247
266
  throw new Error(
248
- `Refusing to install into ${fallbackDir}: the fallback path already belongs to another checkout or directory`
267
+ `Refusing to install into ${fallbackDir}: the fallback path already belongs to another Egregore, checkout, or directory`
249
268
  );
250
269
  }
251
270
  }
@@ -444,11 +463,16 @@ async function install(data, ui, targetDir, opts = {}) {
444
463
  const {
445
464
  egregoreDir,
446
465
  redirectedFrom,
447
- } = resolveEgregoreInstallDir(base, fork_url, slug || github_org || org_name);
466
+ } = resolveEgregoreInstallDir(
467
+ base,
468
+ fork_url,
469
+ slug || github_org || org_name,
470
+ data.org_id || "",
471
+ );
448
472
  if (redirectedFrom) {
449
473
  forkDirName = path.basename(egregoreDir);
450
474
  ui.warn(
451
- `${redirectedFrom} belongs to another Git origin — leaving it untouched and installing at ${egregoreDir}`
475
+ `${redirectedFrom} belongs to another Egregore or Git origin — leaving it untouched and installing at ${egregoreDir}`
452
476
  );
453
477
  }
454
478
  const memoryDirName = memory_url
@@ -1176,10 +1200,23 @@ function registerInstance(slug, name, egregoreDir, runtime) {
1176
1200
  }
1177
1201
  }
1178
1202
 
1203
+ // The checkout config is canonical. A historic same-path install could pass
1204
+ // another organization's label here and splice that label onto this path.
1205
+ let canonicalOrgId = "";
1206
+ try {
1207
+ const config = JSON.parse(
1208
+ fs.readFileSync(path.join(egregoreDir, "egregore.json"), "utf-8")
1209
+ );
1210
+ slug = config.slug || slug;
1211
+ name = config.org_name || name;
1212
+ canonicalOrgId = config.org_id || "";
1213
+ } catch {}
1214
+
1179
1215
  // Dedup by path (not slug) so multiple instances from the same org can coexist
1180
1216
  const idx = instances.findIndex((i) => i.path === egregoreDir);
1181
1217
  const previous = idx >= 0 ? instances[idx] : {};
1182
1218
  const entry = { ...previous, slug, name, path: egregoreDir };
1219
+ if (canonicalOrgId) entry.org_id = canonicalOrgId;
1183
1220
  if (runtime) {
1184
1221
  entry.runtime = normalizeRuntime(runtime);
1185
1222
  } else if (!entry.runtime && entry.preferred_runtime) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.21.0-runtime-mvp.21",
3
+ "version": "0.21.0-runtime-mvp.23",
4
4
  "description": "Set up Egregore for your team in one command — installs the egregore launcher and your choice of Claude Code, Codex, or Pi.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -6,7 +6,12 @@ set -euo pipefail
6
6
  # legacy Local instances create opaque local IDs once. Network failure remains
7
7
  # non-fatal to startup and is retried next session.
8
8
 
9
- ROOT="$(cd "$(dirname "$0")/.." && pwd)"
9
+ RUNTIME_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
10
+ # A launcher-carried bridge runs outside the selected instance. Keep code
11
+ # rooted in the packaged Runtime while resolving and persisting identity in
12
+ # exactly the instance the launcher selected.
13
+ ROOT="${EGREGORE_INSTANCE_ROOT:-$RUNTIME_ROOT}"
14
+ ROOT="$(cd "$ROOT" && pwd)"
10
15
  CONFIG="$ROOT/egregore.json"
11
16
  STATE="$ROOT/.egregore-state.json"
12
17
 
@@ -34,21 +39,52 @@ case "${1:-ensure}" in
34
39
  resolved && exit 0
35
40
  mode="$(jq -r 'if (.mode // "local") == "connected" or (.api_url // "") != "" then "connected" else "local" end' "$CONFIG" 2>/dev/null || echo local)"
36
41
  if [ "$mode" = "local" ]; then
37
- EGREGORE_ROOT="$ROOT" python3 -m egregore_runtime.identity_cli initialize-local >/dev/null
42
+ EGREGORE_ROOT="$ROOT" \
43
+ PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
44
+ python3 -m egregore_runtime.identity_cli initialize-local >/dev/null
38
45
  else
39
46
  api_url="$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)"
40
- api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
41
- [ -n "$api_url" ] && [ -n "$api_key" ] || exit 1
42
- # Identity repair is read-only: select the already-existing account and
43
- # membership records, then adopt their opaque IDs locally. If migration
44
- # 038 has not reached the control plane, the next session retries.
45
- control_plane="$(curl -sf "$api_url/api/org/status" \
46
- -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
47
- 2>/dev/null || true)"
48
- printf '%s' "$control_plane" | jq -e \
49
- '.org_id | type == "string" and length > 0' >/dev/null 2>&1 || exit 1
50
47
  slug="$(jq -r '.slug // empty' "$CONFIG" 2>/dev/null)"
51
- [ -n "$slug" ] || exit 1
48
+ api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
49
+ [ -n "$api_url" ] && [ -n "$slug" ] || exit 1
50
+ # Identity repair selects already-existing account and membership
51
+ # records, then adopts their opaque IDs locally. If a stale credential
52
+ # belongs to another org, recover only the configured org's existing key
53
+ # through verified GitHub membership. No control-plane records are made.
54
+ control_plane=""
55
+ if [ -n "$api_key" ]; then
56
+ control_plane="$(curl -sf "$api_url/api/org/status" \
57
+ -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
58
+ 2>/dev/null || true)"
59
+ fi
60
+ if ! printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
61
+ (.org_id | type == "string" and length > 0) and .slug == $slug
62
+ ' >/dev/null 2>&1; then
63
+ # A historic path/namespace collision could leave this checkout with
64
+ # another Egregore's API key. Recover the configured org's existing key
65
+ # only through the membership-verifying endpoint, install it atomically,
66
+ # and retry. No credential bytes are printed.
67
+ github_token="$(grep '^GITHUB_TOKEN=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
68
+ [ -n "$github_token" ] || exit 1
69
+ encoded_slug="$(printf '%s' "$slug" | jq -sRr @uri)"
70
+ recovered="$(curl -sf "$api_url/api/org/$encoded_slug/key" \
71
+ -H "Authorization: Bearer $github_token" --connect-timeout 2 --max-time 12 \
72
+ 2>/dev/null || true)"
73
+ printf '%s' "$recovered" | jq -e --arg slug "$slug" '
74
+ (.api_key | type == "string" and length > 0) and .org_slug == $slug
75
+ ' >/dev/null 2>&1 || exit 1
76
+ printf '%s' "$recovered" | \
77
+ EGREGORE_ROOT="$ROOT" \
78
+ PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
79
+ python3 -m egregore_runtime.identity_cli install-connected-key >/dev/null
80
+ api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
81
+ control_plane="$(curl -sf "$api_url/api/org/status" \
82
+ -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
83
+ 2>/dev/null || true)"
84
+ printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
85
+ (.org_id | type == "string" and length > 0) and .slug == $slug
86
+ ' >/dev/null 2>&1 || exit 1
87
+ fi
52
88
  roster="$(curl -sf "$api_url/api/org/$slug/members?include_removed=true" \
53
89
  -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
54
90
  2>/dev/null || true)"
@@ -58,7 +94,9 @@ case "${1:-ensure}" in
58
94
  --argjson organization "$control_plane" \
59
95
  --argjson roster "$roster" \
60
96
  '{organization:$organization,roster:$roster}' | \
61
- EGREGORE_ROOT="$ROOT" python3 -m egregore_runtime.identity_cli \
97
+ EGREGORE_ROOT="$ROOT" \
98
+ PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
99
+ python3 -m egregore_runtime.identity_cli \
62
100
  reconcile-records >/dev/null
63
101
  fi
64
102
  resolved
@@ -28,13 +28,35 @@ _runtime() {
28
28
  python3 -m egregore_runtime.harness_cli "$@"
29
29
  }
30
30
 
31
+ _ensure_identity() {
32
+ # Pre-Runtime instances can predate stable organization identity. Resolve it
33
+ # through the existing verified Local/Connected migration before computing
34
+ # the instance-scoped upgrade key; never infer identity from path or slug.
35
+ if jq -e '
36
+ .org_id | type == "string" and length > 0 and (startswith("legacy-org:") | not)
37
+ ' "$INSTANCE_ROOT/egregore.json" >/dev/null 2>&1; then
38
+ return 0
39
+ fi
40
+ EGREGORE_INSTANCE_ROOT="$INSTANCE_ROOT" \
41
+ PYTHONPATH="$SCRIPT_DIR${PYTHONPATH:+:$PYTHONPATH}" \
42
+ bash "$SCRIPT_DIR/bin/runtime-identity.sh" ensure
43
+ }
44
+
45
+ _run_upgrade() {
46
+ _ensure_identity || {
47
+ echo "Runtime update could not resolve this Egregore's stable identity." >&2
48
+ return 1
49
+ }
50
+ _runtime upgrade "$@"
51
+ }
52
+
31
53
  case "${1:-}" in
32
- status) shift; _runtime upgrade status "$@" ;;
33
- init) shift; _runtime upgrade init "$@" ;;
34
- prepare) shift; _runtime upgrade prepare "$@" ;;
35
- activate) shift; _runtime upgrade activate "$@" ;;
36
- cancel) shift; _runtime upgrade cancel "$@" ;;
37
- rollback) shift; _runtime upgrade rollback "$@" ;;
54
+ status) shift; _run_upgrade status "$@" ;;
55
+ init) shift; _run_upgrade init "$@" ;;
56
+ prepare) shift; _run_upgrade prepare "$@" ;;
57
+ activate) shift; _run_upgrade activate "$@" ;;
58
+ cancel) shift; _run_upgrade cancel "$@" ;;
59
+ rollback) shift; _run_upgrade rollback "$@" ;;
38
60
  team-sync) shift; _runtime team-sync "$@" ;;
39
61
  *)
40
62
  echo "usage: bash bin/runtime-upgrade.sh {status|init --candidate <version>|prepare|activate|cancel|rollback|team-sync}" >&2
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import sys
8
+ import tempfile
8
9
  from pathlib import Path
9
10
 
10
11
  from .contracts import Permission
@@ -44,6 +45,55 @@ def _authorize_self(root: Path, permission: Permission, resource: str) -> int:
44
45
  return 0
45
46
 
46
47
 
48
+ def _install_connected_key(root: Path, payload: dict) -> dict[str, str]:
49
+ """Install a membership-verified key without exposing it in output."""
50
+
51
+ config = json.loads((root / "egregore.json").read_text(encoding="utf-8"))
52
+ expected_slug = str(config.get("slug") or "").strip()
53
+ returned_slug = str(payload.get("org_slug") or "").strip()
54
+ api_key = str(payload.get("api_key") or "").strip()
55
+ if not expected_slug or returned_slug != expected_slug:
56
+ raise ValueError("recovered credential does not match the configured organization")
57
+ if not api_key or "\n" in api_key or "\r" in api_key:
58
+ raise ValueError("recovered credential is missing or malformed")
59
+
60
+ env_path = root / ".env"
61
+ existing = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
62
+ output: list[str] = []
63
+ replaced = False
64
+ for line in existing.splitlines():
65
+ if line.startswith("EGREGORE_API_KEY="):
66
+ if not replaced:
67
+ output.append(f"EGREGORE_API_KEY={api_key}")
68
+ replaced = True
69
+ continue
70
+ output.append(line)
71
+ if not replaced:
72
+ output.append(f"EGREGORE_API_KEY={api_key}")
73
+
74
+ temporary: Path | None = None
75
+ try:
76
+ with tempfile.NamedTemporaryFile(
77
+ mode="w",
78
+ encoding="utf-8",
79
+ dir=root,
80
+ prefix=".env.egregore-key.",
81
+ delete=False,
82
+ ) as handle:
83
+ temporary = Path(handle.name)
84
+ os.chmod(temporary, 0o600)
85
+ handle.write("\n".join(output) + "\n")
86
+ handle.flush()
87
+ os.fsync(handle.fileno())
88
+ os.replace(temporary, env_path)
89
+ temporary = None
90
+ finally:
91
+ if temporary is not None:
92
+ temporary.unlink(missing_ok=True)
93
+
94
+ return {"org_slug": expected_slug}
95
+
96
+
47
97
  def main() -> int:
48
98
  root = Path(os.environ.get("EGREGORE_ROOT") or Path.cwd()).resolve()
49
99
  if len(sys.argv) > 1 and sys.argv[1] == "initialize-local":
@@ -77,6 +127,11 @@ def main() -> int:
77
127
  result = reconcile_control_plane_identity(root, response)
78
128
  print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
79
129
  return 0
130
+ if len(sys.argv) > 1 and sys.argv[1] == "install-connected-key":
131
+ payload = json.load(sys.stdin)
132
+ result = _install_connected_key(root, payload)
133
+ print(json.dumps({"status": "installed", **result}, sort_keys=True))
134
+ return 0
80
135
  payload = json.load(sys.stdin)
81
136
  result = reconcile_control_plane_identity(root, payload)
82
137
  print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
@@ -6,7 +6,12 @@ set -euo pipefail
6
6
  # legacy Local instances create opaque local IDs once. Network failure remains
7
7
  # non-fatal to startup and is retried next session.
8
8
 
9
- ROOT="$(cd "$(dirname "$0")/.." && pwd)"
9
+ RUNTIME_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
10
+ # A launcher-carried bridge runs outside the selected instance. Keep code
11
+ # rooted in the packaged Runtime while resolving and persisting identity in
12
+ # exactly the instance the launcher selected.
13
+ ROOT="${EGREGORE_INSTANCE_ROOT:-$RUNTIME_ROOT}"
14
+ ROOT="$(cd "$ROOT" && pwd)"
10
15
  CONFIG="$ROOT/egregore.json"
11
16
  STATE="$ROOT/.egregore-state.json"
12
17
 
@@ -34,21 +39,52 @@ case "${1:-ensure}" in
34
39
  resolved && exit 0
35
40
  mode="$(jq -r 'if (.mode // "local") == "connected" or (.api_url // "") != "" then "connected" else "local" end' "$CONFIG" 2>/dev/null || echo local)"
36
41
  if [ "$mode" = "local" ]; then
37
- EGREGORE_ROOT="$ROOT" python3 -m egregore_runtime.identity_cli initialize-local >/dev/null
42
+ EGREGORE_ROOT="$ROOT" \
43
+ PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
44
+ python3 -m egregore_runtime.identity_cli initialize-local >/dev/null
38
45
  else
39
46
  api_url="$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)"
40
- api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
41
- [ -n "$api_url" ] && [ -n "$api_key" ] || exit 1
42
- # Identity repair is read-only: select the already-existing account and
43
- # membership records, then adopt their opaque IDs locally. If migration
44
- # 038 has not reached the control plane, the next session retries.
45
- control_plane="$(curl -sf "$api_url/api/org/status" \
46
- -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
47
- 2>/dev/null || true)"
48
- printf '%s' "$control_plane" | jq -e \
49
- '.org_id | type == "string" and length > 0' >/dev/null 2>&1 || exit 1
50
47
  slug="$(jq -r '.slug // empty' "$CONFIG" 2>/dev/null)"
51
- [ -n "$slug" ] || exit 1
48
+ api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
49
+ [ -n "$api_url" ] && [ -n "$slug" ] || exit 1
50
+ # Identity repair selects already-existing account and membership
51
+ # records, then adopts their opaque IDs locally. If a stale credential
52
+ # belongs to another org, recover only the configured org's existing key
53
+ # through verified GitHub membership. No control-plane records are made.
54
+ control_plane=""
55
+ if [ -n "$api_key" ]; then
56
+ control_plane="$(curl -sf "$api_url/api/org/status" \
57
+ -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
58
+ 2>/dev/null || true)"
59
+ fi
60
+ if ! printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
61
+ (.org_id | type == "string" and length > 0) and .slug == $slug
62
+ ' >/dev/null 2>&1; then
63
+ # A historic path/namespace collision could leave this checkout with
64
+ # another Egregore's API key. Recover the configured org's existing key
65
+ # only through the membership-verifying endpoint, install it atomically,
66
+ # and retry. No credential bytes are printed.
67
+ github_token="$(grep '^GITHUB_TOKEN=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
68
+ [ -n "$github_token" ] || exit 1
69
+ encoded_slug="$(printf '%s' "$slug" | jq -sRr @uri)"
70
+ recovered="$(curl -sf "$api_url/api/org/$encoded_slug/key" \
71
+ -H "Authorization: Bearer $github_token" --connect-timeout 2 --max-time 12 \
72
+ 2>/dev/null || true)"
73
+ printf '%s' "$recovered" | jq -e --arg slug "$slug" '
74
+ (.api_key | type == "string" and length > 0) and .org_slug == $slug
75
+ ' >/dev/null 2>&1 || exit 1
76
+ printf '%s' "$recovered" | \
77
+ EGREGORE_ROOT="$ROOT" \
78
+ PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
79
+ python3 -m egregore_runtime.identity_cli install-connected-key >/dev/null
80
+ api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
81
+ control_plane="$(curl -sf "$api_url/api/org/status" \
82
+ -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
83
+ 2>/dev/null || true)"
84
+ printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
85
+ (.org_id | type == "string" and length > 0) and .slug == $slug
86
+ ' >/dev/null 2>&1 || exit 1
87
+ fi
52
88
  roster="$(curl -sf "$api_url/api/org/$slug/members?include_removed=true" \
53
89
  -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
54
90
  2>/dev/null || true)"
@@ -58,7 +94,9 @@ case "${1:-ensure}" in
58
94
  --argjson organization "$control_plane" \
59
95
  --argjson roster "$roster" \
60
96
  '{organization:$organization,roster:$roster}' | \
61
- EGREGORE_ROOT="$ROOT" python3 -m egregore_runtime.identity_cli \
97
+ EGREGORE_ROOT="$ROOT" \
98
+ PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
99
+ python3 -m egregore_runtime.identity_cli \
62
100
  reconcile-records >/dev/null
63
101
  fi
64
102
  resolved
@@ -28,13 +28,35 @@ _runtime() {
28
28
  python3 -m egregore_runtime.harness_cli "$@"
29
29
  }
30
30
 
31
+ _ensure_identity() {
32
+ # Pre-Runtime instances can predate stable organization identity. Resolve it
33
+ # through the existing verified Local/Connected migration before computing
34
+ # the instance-scoped upgrade key; never infer identity from path or slug.
35
+ if jq -e '
36
+ .org_id | type == "string" and length > 0 and (startswith("legacy-org:") | not)
37
+ ' "$INSTANCE_ROOT/egregore.json" >/dev/null 2>&1; then
38
+ return 0
39
+ fi
40
+ EGREGORE_INSTANCE_ROOT="$INSTANCE_ROOT" \
41
+ PYTHONPATH="$SCRIPT_DIR${PYTHONPATH:+:$PYTHONPATH}" \
42
+ bash "$SCRIPT_DIR/bin/runtime-identity.sh" ensure
43
+ }
44
+
45
+ _run_upgrade() {
46
+ _ensure_identity || {
47
+ echo "Runtime update could not resolve this Egregore's stable identity." >&2
48
+ return 1
49
+ }
50
+ _runtime upgrade "$@"
51
+ }
52
+
31
53
  case "${1:-}" in
32
- status) shift; _runtime upgrade status "$@" ;;
33
- init) shift; _runtime upgrade init "$@" ;;
34
- prepare) shift; _runtime upgrade prepare "$@" ;;
35
- activate) shift; _runtime upgrade activate "$@" ;;
36
- cancel) shift; _runtime upgrade cancel "$@" ;;
37
- rollback) shift; _runtime upgrade rollback "$@" ;;
54
+ status) shift; _run_upgrade status "$@" ;;
55
+ init) shift; _run_upgrade init "$@" ;;
56
+ prepare) shift; _run_upgrade prepare "$@" ;;
57
+ activate) shift; _run_upgrade activate "$@" ;;
58
+ cancel) shift; _run_upgrade cancel "$@" ;;
59
+ rollback) shift; _run_upgrade rollback "$@" ;;
38
60
  team-sync) shift; _runtime team-sync "$@" ;;
39
61
  *)
40
62
  echo "usage: bash bin/runtime-upgrade.sh {status|init --candidate <version>|prepare|activate|cancel|rollback|team-sync}" >&2
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import sys
8
+ import tempfile
8
9
  from pathlib import Path
9
10
 
10
11
  from .contracts import Permission
@@ -44,6 +45,55 @@ def _authorize_self(root: Path, permission: Permission, resource: str) -> int:
44
45
  return 0
45
46
 
46
47
 
48
+ def _install_connected_key(root: Path, payload: dict) -> dict[str, str]:
49
+ """Install a membership-verified key without exposing it in output."""
50
+
51
+ config = json.loads((root / "egregore.json").read_text(encoding="utf-8"))
52
+ expected_slug = str(config.get("slug") or "").strip()
53
+ returned_slug = str(payload.get("org_slug") or "").strip()
54
+ api_key = str(payload.get("api_key") or "").strip()
55
+ if not expected_slug or returned_slug != expected_slug:
56
+ raise ValueError("recovered credential does not match the configured organization")
57
+ if not api_key or "\n" in api_key or "\r" in api_key:
58
+ raise ValueError("recovered credential is missing or malformed")
59
+
60
+ env_path = root / ".env"
61
+ existing = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
62
+ output: list[str] = []
63
+ replaced = False
64
+ for line in existing.splitlines():
65
+ if line.startswith("EGREGORE_API_KEY="):
66
+ if not replaced:
67
+ output.append(f"EGREGORE_API_KEY={api_key}")
68
+ replaced = True
69
+ continue
70
+ output.append(line)
71
+ if not replaced:
72
+ output.append(f"EGREGORE_API_KEY={api_key}")
73
+
74
+ temporary: Path | None = None
75
+ try:
76
+ with tempfile.NamedTemporaryFile(
77
+ mode="w",
78
+ encoding="utf-8",
79
+ dir=root,
80
+ prefix=".env.egregore-key.",
81
+ delete=False,
82
+ ) as handle:
83
+ temporary = Path(handle.name)
84
+ os.chmod(temporary, 0o600)
85
+ handle.write("\n".join(output) + "\n")
86
+ handle.flush()
87
+ os.fsync(handle.fileno())
88
+ os.replace(temporary, env_path)
89
+ temporary = None
90
+ finally:
91
+ if temporary is not None:
92
+ temporary.unlink(missing_ok=True)
93
+
94
+ return {"org_slug": expected_slug}
95
+
96
+
47
97
  def main() -> int:
48
98
  root = Path(os.environ.get("EGREGORE_ROOT") or Path.cwd()).resolve()
49
99
  if len(sys.argv) > 1 and sys.argv[1] == "initialize-local":
@@ -77,6 +127,11 @@ def main() -> int:
77
127
  result = reconcile_control_plane_identity(root, response)
78
128
  print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
79
129
  return 0
130
+ if len(sys.argv) > 1 and sys.argv[1] == "install-connected-key":
131
+ payload = json.load(sys.stdin)
132
+ result = _install_connected_key(root, payload)
133
+ print(json.dumps({"status": "installed", **result}, sort_keys=True))
134
+ return 0
80
135
  payload = json.load(sys.stdin)
81
136
  result = reconcile_control_plane_identity(root, payload)
82
137
  print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
@@ -6,7 +6,12 @@ set -euo pipefail
6
6
  # legacy Local instances create opaque local IDs once. Network failure remains
7
7
  # non-fatal to startup and is retried next session.
8
8
 
9
- ROOT="$(cd "$(dirname "$0")/.." && pwd)"
9
+ RUNTIME_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
10
+ # A launcher-carried bridge runs outside the selected instance. Keep code
11
+ # rooted in the packaged Runtime while resolving and persisting identity in
12
+ # exactly the instance the launcher selected.
13
+ ROOT="${EGREGORE_INSTANCE_ROOT:-$RUNTIME_ROOT}"
14
+ ROOT="$(cd "$ROOT" && pwd)"
10
15
  CONFIG="$ROOT/egregore.json"
11
16
  STATE="$ROOT/.egregore-state.json"
12
17
 
@@ -34,21 +39,52 @@ case "${1:-ensure}" in
34
39
  resolved && exit 0
35
40
  mode="$(jq -r 'if (.mode // "local") == "connected" or (.api_url // "") != "" then "connected" else "local" end' "$CONFIG" 2>/dev/null || echo local)"
36
41
  if [ "$mode" = "local" ]; then
37
- EGREGORE_ROOT="$ROOT" python3 -m egregore_runtime.identity_cli initialize-local >/dev/null
42
+ EGREGORE_ROOT="$ROOT" \
43
+ PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
44
+ python3 -m egregore_runtime.identity_cli initialize-local >/dev/null
38
45
  else
39
46
  api_url="$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)"
40
- api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
41
- [ -n "$api_url" ] && [ -n "$api_key" ] || exit 1
42
- # Identity repair is read-only: select the already-existing account and
43
- # membership records, then adopt their opaque IDs locally. If migration
44
- # 038 has not reached the control plane, the next session retries.
45
- control_plane="$(curl -sf "$api_url/api/org/status" \
46
- -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
47
- 2>/dev/null || true)"
48
- printf '%s' "$control_plane" | jq -e \
49
- '.org_id | type == "string" and length > 0' >/dev/null 2>&1 || exit 1
50
47
  slug="$(jq -r '.slug // empty' "$CONFIG" 2>/dev/null)"
51
- [ -n "$slug" ] || exit 1
48
+ api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
49
+ [ -n "$api_url" ] && [ -n "$slug" ] || exit 1
50
+ # Identity repair selects already-existing account and membership
51
+ # records, then adopts their opaque IDs locally. If a stale credential
52
+ # belongs to another org, recover only the configured org's existing key
53
+ # through verified GitHub membership. No control-plane records are made.
54
+ control_plane=""
55
+ if [ -n "$api_key" ]; then
56
+ control_plane="$(curl -sf "$api_url/api/org/status" \
57
+ -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
58
+ 2>/dev/null || true)"
59
+ fi
60
+ if ! printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
61
+ (.org_id | type == "string" and length > 0) and .slug == $slug
62
+ ' >/dev/null 2>&1; then
63
+ # A historic path/namespace collision could leave this checkout with
64
+ # another Egregore's API key. Recover the configured org's existing key
65
+ # only through the membership-verifying endpoint, install it atomically,
66
+ # and retry. No credential bytes are printed.
67
+ github_token="$(grep '^GITHUB_TOKEN=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
68
+ [ -n "$github_token" ] || exit 1
69
+ encoded_slug="$(printf '%s' "$slug" | jq -sRr @uri)"
70
+ recovered="$(curl -sf "$api_url/api/org/$encoded_slug/key" \
71
+ -H "Authorization: Bearer $github_token" --connect-timeout 2 --max-time 12 \
72
+ 2>/dev/null || true)"
73
+ printf '%s' "$recovered" | jq -e --arg slug "$slug" '
74
+ (.api_key | type == "string" and length > 0) and .org_slug == $slug
75
+ ' >/dev/null 2>&1 || exit 1
76
+ printf '%s' "$recovered" | \
77
+ EGREGORE_ROOT="$ROOT" \
78
+ PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
79
+ python3 -m egregore_runtime.identity_cli install-connected-key >/dev/null
80
+ api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
81
+ control_plane="$(curl -sf "$api_url/api/org/status" \
82
+ -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
83
+ 2>/dev/null || true)"
84
+ printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
85
+ (.org_id | type == "string" and length > 0) and .slug == $slug
86
+ ' >/dev/null 2>&1 || exit 1
87
+ fi
52
88
  roster="$(curl -sf "$api_url/api/org/$slug/members?include_removed=true" \
53
89
  -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
54
90
  2>/dev/null || true)"
@@ -58,7 +94,9 @@ case "${1:-ensure}" in
58
94
  --argjson organization "$control_plane" \
59
95
  --argjson roster "$roster" \
60
96
  '{organization:$organization,roster:$roster}' | \
61
- EGREGORE_ROOT="$ROOT" python3 -m egregore_runtime.identity_cli \
97
+ EGREGORE_ROOT="$ROOT" \
98
+ PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
99
+ python3 -m egregore_runtime.identity_cli \
62
100
  reconcile-records >/dev/null
63
101
  fi
64
102
  resolved
@@ -28,13 +28,35 @@ _runtime() {
28
28
  python3 -m egregore_runtime.harness_cli "$@"
29
29
  }
30
30
 
31
+ _ensure_identity() {
32
+ # Pre-Runtime instances can predate stable organization identity. Resolve it
33
+ # through the existing verified Local/Connected migration before computing
34
+ # the instance-scoped upgrade key; never infer identity from path or slug.
35
+ if jq -e '
36
+ .org_id | type == "string" and length > 0 and (startswith("legacy-org:") | not)
37
+ ' "$INSTANCE_ROOT/egregore.json" >/dev/null 2>&1; then
38
+ return 0
39
+ fi
40
+ EGREGORE_INSTANCE_ROOT="$INSTANCE_ROOT" \
41
+ PYTHONPATH="$SCRIPT_DIR${PYTHONPATH:+:$PYTHONPATH}" \
42
+ bash "$SCRIPT_DIR/bin/runtime-identity.sh" ensure
43
+ }
44
+
45
+ _run_upgrade() {
46
+ _ensure_identity || {
47
+ echo "Runtime update could not resolve this Egregore's stable identity." >&2
48
+ return 1
49
+ }
50
+ _runtime upgrade "$@"
51
+ }
52
+
31
53
  case "${1:-}" in
32
- status) shift; _runtime upgrade status "$@" ;;
33
- init) shift; _runtime upgrade init "$@" ;;
34
- prepare) shift; _runtime upgrade prepare "$@" ;;
35
- activate) shift; _runtime upgrade activate "$@" ;;
36
- cancel) shift; _runtime upgrade cancel "$@" ;;
37
- rollback) shift; _runtime upgrade rollback "$@" ;;
54
+ status) shift; _run_upgrade status "$@" ;;
55
+ init) shift; _run_upgrade init "$@" ;;
56
+ prepare) shift; _run_upgrade prepare "$@" ;;
57
+ activate) shift; _run_upgrade activate "$@" ;;
58
+ cancel) shift; _run_upgrade cancel "$@" ;;
59
+ rollback) shift; _run_upgrade rollback "$@" ;;
38
60
  team-sync) shift; _runtime team-sync "$@" ;;
39
61
  *)
40
62
  echo "usage: bash bin/runtime-upgrade.sh {status|init --candidate <version>|prepare|activate|cancel|rollback|team-sync}" >&2
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import sys
8
+ import tempfile
8
9
  from pathlib import Path
9
10
 
10
11
  from .contracts import Permission
@@ -44,6 +45,55 @@ def _authorize_self(root: Path, permission: Permission, resource: str) -> int:
44
45
  return 0
45
46
 
46
47
 
48
+ def _install_connected_key(root: Path, payload: dict) -> dict[str, str]:
49
+ """Install a membership-verified key without exposing it in output."""
50
+
51
+ config = json.loads((root / "egregore.json").read_text(encoding="utf-8"))
52
+ expected_slug = str(config.get("slug") or "").strip()
53
+ returned_slug = str(payload.get("org_slug") or "").strip()
54
+ api_key = str(payload.get("api_key") or "").strip()
55
+ if not expected_slug or returned_slug != expected_slug:
56
+ raise ValueError("recovered credential does not match the configured organization")
57
+ if not api_key or "\n" in api_key or "\r" in api_key:
58
+ raise ValueError("recovered credential is missing or malformed")
59
+
60
+ env_path = root / ".env"
61
+ existing = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
62
+ output: list[str] = []
63
+ replaced = False
64
+ for line in existing.splitlines():
65
+ if line.startswith("EGREGORE_API_KEY="):
66
+ if not replaced:
67
+ output.append(f"EGREGORE_API_KEY={api_key}")
68
+ replaced = True
69
+ continue
70
+ output.append(line)
71
+ if not replaced:
72
+ output.append(f"EGREGORE_API_KEY={api_key}")
73
+
74
+ temporary: Path | None = None
75
+ try:
76
+ with tempfile.NamedTemporaryFile(
77
+ mode="w",
78
+ encoding="utf-8",
79
+ dir=root,
80
+ prefix=".env.egregore-key.",
81
+ delete=False,
82
+ ) as handle:
83
+ temporary = Path(handle.name)
84
+ os.chmod(temporary, 0o600)
85
+ handle.write("\n".join(output) + "\n")
86
+ handle.flush()
87
+ os.fsync(handle.fileno())
88
+ os.replace(temporary, env_path)
89
+ temporary = None
90
+ finally:
91
+ if temporary is not None:
92
+ temporary.unlink(missing_ok=True)
93
+
94
+ return {"org_slug": expected_slug}
95
+
96
+
47
97
  def main() -> int:
48
98
  root = Path(os.environ.get("EGREGORE_ROOT") or Path.cwd()).resolve()
49
99
  if len(sys.argv) > 1 and sys.argv[1] == "initialize-local":
@@ -77,6 +127,11 @@ def main() -> int:
77
127
  result = reconcile_control_plane_identity(root, response)
78
128
  print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
79
129
  return 0
130
+ if len(sys.argv) > 1 and sys.argv[1] == "install-connected-key":
131
+ payload = json.load(sys.stdin)
132
+ result = _install_connected_key(root, payload)
133
+ print(json.dumps({"status": "installed", **result}, sort_keys=True))
134
+ return 0
80
135
  payload = json.load(sys.stdin)
81
136
  result = reconcile_control_plane_identity(root, payload)
82
137
  print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
@@ -6,7 +6,12 @@ set -euo pipefail
6
6
  # legacy Local instances create opaque local IDs once. Network failure remains
7
7
  # non-fatal to startup and is retried next session.
8
8
 
9
- ROOT="$(cd "$(dirname "$0")/.." && pwd)"
9
+ RUNTIME_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
10
+ # A launcher-carried bridge runs outside the selected instance. Keep code
11
+ # rooted in the packaged Runtime while resolving and persisting identity in
12
+ # exactly the instance the launcher selected.
13
+ ROOT="${EGREGORE_INSTANCE_ROOT:-$RUNTIME_ROOT}"
14
+ ROOT="$(cd "$ROOT" && pwd)"
10
15
  CONFIG="$ROOT/egregore.json"
11
16
  STATE="$ROOT/.egregore-state.json"
12
17
 
@@ -34,21 +39,52 @@ case "${1:-ensure}" in
34
39
  resolved && exit 0
35
40
  mode="$(jq -r 'if (.mode // "local") == "connected" or (.api_url // "") != "" then "connected" else "local" end' "$CONFIG" 2>/dev/null || echo local)"
36
41
  if [ "$mode" = "local" ]; then
37
- EGREGORE_ROOT="$ROOT" python3 -m egregore_runtime.identity_cli initialize-local >/dev/null
42
+ EGREGORE_ROOT="$ROOT" \
43
+ PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
44
+ python3 -m egregore_runtime.identity_cli initialize-local >/dev/null
38
45
  else
39
46
  api_url="$(jq -r '.api_url // empty' "$CONFIG" 2>/dev/null)"
40
- api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
41
- [ -n "$api_url" ] && [ -n "$api_key" ] || exit 1
42
- # Identity repair is read-only: select the already-existing account and
43
- # membership records, then adopt their opaque IDs locally. If migration
44
- # 038 has not reached the control plane, the next session retries.
45
- control_plane="$(curl -sf "$api_url/api/org/status" \
46
- -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
47
- 2>/dev/null || true)"
48
- printf '%s' "$control_plane" | jq -e \
49
- '.org_id | type == "string" and length > 0' >/dev/null 2>&1 || exit 1
50
47
  slug="$(jq -r '.slug // empty' "$CONFIG" 2>/dev/null)"
51
- [ -n "$slug" ] || exit 1
48
+ api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
49
+ [ -n "$api_url" ] && [ -n "$slug" ] || exit 1
50
+ # Identity repair selects already-existing account and membership
51
+ # records, then adopts their opaque IDs locally. If a stale credential
52
+ # belongs to another org, recover only the configured org's existing key
53
+ # through verified GitHub membership. No control-plane records are made.
54
+ control_plane=""
55
+ if [ -n "$api_key" ]; then
56
+ control_plane="$(curl -sf "$api_url/api/org/status" \
57
+ -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
58
+ 2>/dev/null || true)"
59
+ fi
60
+ if ! printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
61
+ (.org_id | type == "string" and length > 0) and .slug == $slug
62
+ ' >/dev/null 2>&1; then
63
+ # A historic path/namespace collision could leave this checkout with
64
+ # another Egregore's API key. Recover the configured org's existing key
65
+ # only through the membership-verifying endpoint, install it atomically,
66
+ # and retry. No credential bytes are printed.
67
+ github_token="$(grep '^GITHUB_TOKEN=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
68
+ [ -n "$github_token" ] || exit 1
69
+ encoded_slug="$(printf '%s' "$slug" | jq -sRr @uri)"
70
+ recovered="$(curl -sf "$api_url/api/org/$encoded_slug/key" \
71
+ -H "Authorization: Bearer $github_token" --connect-timeout 2 --max-time 12 \
72
+ 2>/dev/null || true)"
73
+ printf '%s' "$recovered" | jq -e --arg slug "$slug" '
74
+ (.api_key | type == "string" and length > 0) and .org_slug == $slug
75
+ ' >/dev/null 2>&1 || exit 1
76
+ printf '%s' "$recovered" | \
77
+ EGREGORE_ROOT="$ROOT" \
78
+ PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
79
+ python3 -m egregore_runtime.identity_cli install-connected-key >/dev/null
80
+ api_key="$(grep '^EGREGORE_API_KEY=' "$ROOT/.env" 2>/dev/null | cut -d= -f2-)"
81
+ control_plane="$(curl -sf "$api_url/api/org/status" \
82
+ -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
83
+ 2>/dev/null || true)"
84
+ printf '%s' "$control_plane" | jq -e --arg slug "$slug" '
85
+ (.org_id | type == "string" and length > 0) and .slug == $slug
86
+ ' >/dev/null 2>&1 || exit 1
87
+ fi
52
88
  roster="$(curl -sf "$api_url/api/org/$slug/members?include_removed=true" \
53
89
  -H "Authorization: Bearer $api_key" --connect-timeout 2 --max-time 4 \
54
90
  2>/dev/null || true)"
@@ -58,7 +94,9 @@ case "${1:-ensure}" in
58
94
  --argjson organization "$control_plane" \
59
95
  --argjson roster "$roster" \
60
96
  '{organization:$organization,roster:$roster}' | \
61
- EGREGORE_ROOT="$ROOT" python3 -m egregore_runtime.identity_cli \
97
+ EGREGORE_ROOT="$ROOT" \
98
+ PYTHONPATH="$RUNTIME_ROOT${PYTHONPATH:+:$PYTHONPATH}" \
99
+ python3 -m egregore_runtime.identity_cli \
62
100
  reconcile-records >/dev/null
63
101
  fi
64
102
  resolved
@@ -28,13 +28,35 @@ _runtime() {
28
28
  python3 -m egregore_runtime.harness_cli "$@"
29
29
  }
30
30
 
31
+ _ensure_identity() {
32
+ # Pre-Runtime instances can predate stable organization identity. Resolve it
33
+ # through the existing verified Local/Connected migration before computing
34
+ # the instance-scoped upgrade key; never infer identity from path or slug.
35
+ if jq -e '
36
+ .org_id | type == "string" and length > 0 and (startswith("legacy-org:") | not)
37
+ ' "$INSTANCE_ROOT/egregore.json" >/dev/null 2>&1; then
38
+ return 0
39
+ fi
40
+ EGREGORE_INSTANCE_ROOT="$INSTANCE_ROOT" \
41
+ PYTHONPATH="$SCRIPT_DIR${PYTHONPATH:+:$PYTHONPATH}" \
42
+ bash "$SCRIPT_DIR/bin/runtime-identity.sh" ensure
43
+ }
44
+
45
+ _run_upgrade() {
46
+ _ensure_identity || {
47
+ echo "Runtime update could not resolve this Egregore's stable identity." >&2
48
+ return 1
49
+ }
50
+ _runtime upgrade "$@"
51
+ }
52
+
31
53
  case "${1:-}" in
32
- status) shift; _runtime upgrade status "$@" ;;
33
- init) shift; _runtime upgrade init "$@" ;;
34
- prepare) shift; _runtime upgrade prepare "$@" ;;
35
- activate) shift; _runtime upgrade activate "$@" ;;
36
- cancel) shift; _runtime upgrade cancel "$@" ;;
37
- rollback) shift; _runtime upgrade rollback "$@" ;;
54
+ status) shift; _run_upgrade status "$@" ;;
55
+ init) shift; _run_upgrade init "$@" ;;
56
+ prepare) shift; _run_upgrade prepare "$@" ;;
57
+ activate) shift; _run_upgrade activate "$@" ;;
58
+ cancel) shift; _run_upgrade cancel "$@" ;;
59
+ rollback) shift; _run_upgrade rollback "$@" ;;
38
60
  team-sync) shift; _runtime team-sync "$@" ;;
39
61
  *)
40
62
  echo "usage: bash bin/runtime-upgrade.sh {status|init --candidate <version>|prepare|activate|cancel|rollback|team-sync}" >&2
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import sys
8
+ import tempfile
8
9
  from pathlib import Path
9
10
 
10
11
  from .contracts import Permission
@@ -44,6 +45,55 @@ def _authorize_self(root: Path, permission: Permission, resource: str) -> int:
44
45
  return 0
45
46
 
46
47
 
48
+ def _install_connected_key(root: Path, payload: dict) -> dict[str, str]:
49
+ """Install a membership-verified key without exposing it in output."""
50
+
51
+ config = json.loads((root / "egregore.json").read_text(encoding="utf-8"))
52
+ expected_slug = str(config.get("slug") or "").strip()
53
+ returned_slug = str(payload.get("org_slug") or "").strip()
54
+ api_key = str(payload.get("api_key") or "").strip()
55
+ if not expected_slug or returned_slug != expected_slug:
56
+ raise ValueError("recovered credential does not match the configured organization")
57
+ if not api_key or "\n" in api_key or "\r" in api_key:
58
+ raise ValueError("recovered credential is missing or malformed")
59
+
60
+ env_path = root / ".env"
61
+ existing = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
62
+ output: list[str] = []
63
+ replaced = False
64
+ for line in existing.splitlines():
65
+ if line.startswith("EGREGORE_API_KEY="):
66
+ if not replaced:
67
+ output.append(f"EGREGORE_API_KEY={api_key}")
68
+ replaced = True
69
+ continue
70
+ output.append(line)
71
+ if not replaced:
72
+ output.append(f"EGREGORE_API_KEY={api_key}")
73
+
74
+ temporary: Path | None = None
75
+ try:
76
+ with tempfile.NamedTemporaryFile(
77
+ mode="w",
78
+ encoding="utf-8",
79
+ dir=root,
80
+ prefix=".env.egregore-key.",
81
+ delete=False,
82
+ ) as handle:
83
+ temporary = Path(handle.name)
84
+ os.chmod(temporary, 0o600)
85
+ handle.write("\n".join(output) + "\n")
86
+ handle.flush()
87
+ os.fsync(handle.fileno())
88
+ os.replace(temporary, env_path)
89
+ temporary = None
90
+ finally:
91
+ if temporary is not None:
92
+ temporary.unlink(missing_ok=True)
93
+
94
+ return {"org_slug": expected_slug}
95
+
96
+
47
97
  def main() -> int:
48
98
  root = Path(os.environ.get("EGREGORE_ROOT") or Path.cwd()).resolve()
49
99
  if len(sys.argv) > 1 and sys.argv[1] == "initialize-local":
@@ -77,6 +127,11 @@ def main() -> int:
77
127
  result = reconcile_control_plane_identity(root, response)
78
128
  print(json.dumps({"status": "reconciled", **result}, sort_keys=True))
79
129
  return 0
130
+ if len(sys.argv) > 1 and sys.argv[1] == "install-connected-key":
131
+ payload = json.load(sys.stdin)
132
+ result = _install_connected_key(root, payload)
133
+ print(json.dumps({"status": "installed", **result}, sort_keys=True))
134
+ return 0
80
135
  payload = json.load(sys.stdin)
81
136
  result = reconcile_control_plane_identity(root, payload)
82
137
  print(json.dumps({"status": "reconciled", **result}, sort_keys=True))