@pharaoh-so/mcp 0.3.53 → 0.3.56

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 (43) hide show
  1. package/.mcp.json +1 -1
  2. package/CHANGELOG.md +41 -0
  3. package/LICENSE +1 -1
  4. package/NOTICE +28 -0
  5. package/README.md +5 -5
  6. package/commands/plan.md +11 -11
  7. package/commands/review.md +2 -2
  8. package/dist/auth.d.ts +5 -0
  9. package/dist/credentials.d.ts +8 -0
  10. package/dist/gateway.js +1 -1
  11. package/dist/helpers.d.ts +15 -5
  12. package/dist/helpers.js +71 -2
  13. package/dist/index.js +51 -26
  14. package/dist/install-skills.d.ts +30 -5
  15. package/dist/install-skills.js +210 -11
  16. package/dist/refresh.d.ts +8 -2
  17. package/dist/refresh.js +33 -6
  18. package/dist/setup.d.ts +5 -0
  19. package/dist/setup.js +66 -0
  20. package/inspect-tools.json +89 -36
  21. package/{skills → npx-skills}/brainstorm/SKILL.md +2 -1
  22. package/{skills → npx-skills}/debug/SKILL.md +2 -1
  23. package/{skills → npx-skills}/pharaoh/SKILL.md +13 -6
  24. package/{skills → npx-skills}/plan/SKILL.md +13 -14
  25. package/npx-skills/refactor/SKILL.md +38 -0
  26. package/{skills → npx-skills}/review/SKILL.md +4 -5
  27. package/{skills → npx-skills}/tdd/SKILL.md +2 -1
  28. package/{skills → npx-skills}/verify/SKILL.md +2 -1
  29. package/package.json +6 -4
  30. package/project-memory/bundle.mjs +137 -0
  31. package/project-memory/execute-turn.mjs +240 -0
  32. package/project-memory/hook-config.mjs +91 -0
  33. package/project-memory/hook-events.mjs +141 -0
  34. package/project-memory/local.mjs +463 -0
  35. package/project-memory/memory-api.mjs +182 -0
  36. package/project-memory/pharaoh-memory-hook.mjs +33 -0
  37. package/project-memory/pharaoh-memory.mjs +55 -0
  38. package/project-memory/setup.d.mts +14 -0
  39. package/project-memory/setup.mjs +151 -0
  40. package/project-memory/user-target.mjs +221 -0
  41. package/trace-app.html +222 -101
  42. package/skills/.gitkeep +0 -0
  43. package/skills/refactor/SKILL.md +0 -34
@@ -6,7 +6,7 @@
6
6
  * and OpenClaw skills in ~/.openclaw/skills/. Also merges the Pharaoh MCP
7
7
  * server config into OpenClaw.
8
8
  */
9
- import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
9
+ import { closeSync, constants, cpSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
@@ -16,8 +16,11 @@ import { getPackageVersion, PACKAGE_NAME, pinnedPackageSpec } from "./version.js
16
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
17
  /** Path to the bundled package root (one level up from dist/). */
18
18
  const PKG_ROOT = join(__dirname, "..");
19
- /** Path to the bundled skills directory. */
20
- const BUNDLED_SKILLS_DIR = join(PKG_ROOT, "skills");
19
+ /**
20
+ * Path to the bundled skills: npx's render of skills/ (written by the build's
21
+ * generate:commands), so installs copy them as-is and never see channel markers.
22
+ */
23
+ const BUNDLED_SKILLS_DIR = join(PKG_ROOT, "npx-skills");
21
24
  /** Path to the bundled slash commands directory. */
22
25
  const BUNDLED_COMMANDS_DIR = join(PKG_ROOT, "commands");
23
26
  /** Path to the bundled Claude Code hooks (hooks.json and the Grep routing hook). */
@@ -202,8 +205,9 @@ function removeLegacyClaudePlugin(home) {
202
205
  /** Plugin id the pre-marketplace installer wrote into installed_plugins.json. */
203
206
  const LEGACY_PLUGIN_ID = "pharaoh@pharaoh-so";
204
207
  /**
205
- * Uninstall Pharaoh's Claude Code plugin: through the CLI, then the local
206
- * marketplace directory and any pre-marketplace install.
208
+ * Uninstall Pharaoh's Claude Code plugin (through the CLI, then the local
209
+ * marketplace directory and any pre-marketplace install) and its block in
210
+ * ~/.claude/CLAUDE.md. Run by --clean and --logout.
207
211
  *
208
212
  * @param home - Home directory override (defaults to os.homedir()).
209
213
  * @param claude - `claude` CLI runner (defaults to the real CLI for `home`).
@@ -213,6 +217,7 @@ export function uninstallClaudePlugin(home = homedir(), claude = claudeCliFor(ho
213
217
  claude(["plugin", "marketplace", "remove", MARKETPLACE_NAME]);
214
218
  rmSync(claudeMarketplaceDir(home), { recursive: true, force: true });
215
219
  removeLegacyClaudePlugin(home);
220
+ removeClaudeMdBlock(home);
216
221
  }
217
222
  /**
218
223
  * Copy every skill directory from `srcDir` to `dstDir`, prefixing both the
@@ -604,10 +609,11 @@ export function mergeOpenClawConfig(home = homedir()) {
604
609
  // ── Claude Code permissions ─────────────────────────────────────────
605
610
  /**
606
611
  * Rules the interactive setup adds to `permissions.allow` in
607
- * ~/.claude/settings.json: the six read-only tools and `pharaoh_account`,
612
+ * ~/.claude/settings.json: the seven read-only tools (`readOnlyHint: true`),
608
613
  * each by full name. Never a prefix rule — `mcp__pharaoh` also auto-approves
609
- * account deletion and `pharaoh_admin`, so a prompt injection could run them
610
- * with no human in the loop (security review SC-6). Those tools keep prompting.
614
+ * the tools that change things (`pharaoh_manage_repo`, which queues a re-map,
615
+ * and the superadmin tools), so a prompt injection could run them with
616
+ * no human in the loop (security review SC-6). Those tools keep prompting.
611
617
  */
612
618
  export const PHARAOH_ALLOW_RULES = [
613
619
  "mcp__pharaoh__get_codebase_map",
@@ -664,17 +670,210 @@ export function describeAllowReadTools(result) {
664
670
  const count = PHARAOH_ALLOW_RULES.length;
665
671
  switch (result) {
666
672
  case "added":
667
- return `Pharaoh: allowed ${count} tools by name in ~/.claude/settings.json (permissions.allow): the read-only tools and pharaoh_account. Account deletion and pharaoh_admin still prompt.`;
673
+ return `Pharaoh: allowed ${count} read-only tools by name in ~/.claude/settings.json (permissions.allow). pharaoh_manage_repo, which switches or refreshes a repo, still asks first.`;
668
674
  case "migrated":
669
- return `Pharaoh: replaced the blanket mcp__pharaoh rule in ~/.claude/settings.json with ${count} tools by name. Account deletion and pharaoh_admin prompt again.`;
675
+ return `Pharaoh: replaced the blanket mcp__pharaoh rule in ~/.claude/settings.json with ${count} read-only tools by name. pharaoh_manage_repo asks first again.`;
670
676
  case "current":
671
- return `Pharaoh: ~/.claude/settings.json already allows the ${count} read-only and account tools by name.`;
677
+ return `Pharaoh: ~/.claude/settings.json already allows the ${count} read-only tools by name.`;
672
678
  case "no-settings":
673
679
  return "Pharaoh: no ~/.claude/settings.json yet, so Pharaoh's tools ask before they run. The rules to allow the read-only tools are in the README (Security).";
674
680
  case "error":
675
681
  return "Pharaoh: ~/.claude/settings.json is not valid JSON; left as is. Pharaoh's tools ask before they run.";
676
682
  }
677
683
  }
684
+ // ── Claude Code instructions (~/.claude/CLAUDE.md) ──────────────────
685
+ //
686
+ // Agents acted on the Pharaoh-first line only from a CLAUDE.md, not from server
687
+ // instructions or a hook (benchmarks/adoption/results/2026-10-23-everyday-auto-line.md).
688
+ // The file is the user's: the interactive setup adds the line on a yes, inside
689
+ // markers, and --clean/--logout remove exactly what it wrote.
690
+ /** The Pharaoh-first line; same text as PHARAOH_SCOPED_LINE in scripts/adoption-grade.ts. */
691
+ const PHARAOH_CLAUDE_MD_LINE = "In repos Pharaoh has mapped, before searching or reading files, ask Pharaoh where the relevant code is, what calls it, and whether what you need already exists.";
692
+ /** What the interactive setup asks before writing the line. */
693
+ export const CLAUDE_MD_QUESTION = `Pharaoh: add one line to ~/.claude/CLAUDE.md so Claude checks Pharaoh before searching your code?\n "${PHARAOH_CLAUDE_MD_LINE}"\n[Y/n] `;
694
+ /** File under ~/.pharaoh holding the user's answer, so setup asks once. */
695
+ const CLAUDE_MD_ANSWER_FILE = "claude-md-answer";
696
+ /**
697
+ * Open flags that refuse a symlink even if one is swapped in after the check
698
+ * (SC-7; O_NOFOLLOW is undefined on Windows). A new block is appended, so the
699
+ * user's bytes are never rewritten; a stale block or a removal rewrites the file.
700
+ */
701
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
702
+ const APPEND_NO_FOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | NO_FOLLOW;
703
+ const REWRITE_NO_FOLLOW = constants.O_WRONLY | constants.O_TRUNC | NO_FOLLOW;
704
+ /** Write `text` to `path` opened with `flags` (APPEND_NO_FOLLOW or REWRITE_NO_FOLLOW). */
705
+ function writeNoFollow(path, text, flags) {
706
+ const fd = openSync(path, flags);
707
+ try {
708
+ writeFileSync(fd, text);
709
+ }
710
+ finally {
711
+ closeSync(fd);
712
+ }
713
+ }
714
+ /**
715
+ * Pharaoh's block. `pharaoh-mcp:start+nl` records that the installer also added
716
+ * the newline before it (the file didn't end in one), so removal takes it back too.
717
+ */
718
+ function claudeMdBlock(addedNewline) {
719
+ const start = `<!-- pharaoh-mcp:start${addedNewline ? "+nl" : ""} (managed by npx ${PACKAGE_NAME}; --clean or --logout removes it) -->`;
720
+ return `${start}\n${PHARAOH_CLAUDE_MD_LINE}\n<!-- pharaoh-mcp:end -->\n`;
721
+ }
722
+ /**
723
+ * Find the one Pharaoh block in `text`: null when there is none, "broken"
724
+ * when a marker is missing its pair, repeated or out of order.
725
+ */
726
+ function findClaudeMdBlock(text) {
727
+ const starts = [...text.matchAll(/^<!-- pharaoh-mcp:start(\+nl)?\b[^\n]*\n/gm)];
728
+ const ends = [...text.matchAll(/^<!-- pharaoh-mcp:end -->\n?/gm)];
729
+ if (starts.length === 0 && ends.length === 0)
730
+ return null;
731
+ const [start, end] = [starts[0], ends[0]];
732
+ const body = start ? start.index + start[0].length : 0;
733
+ if (starts.length !== 1 || ends.length !== 1 || end.index < body)
734
+ return "broken";
735
+ return {
736
+ from: start.index,
737
+ body,
738
+ to: end.index,
739
+ through: end.index + end[0].length,
740
+ addedNewline: start[1] !== undefined,
741
+ };
742
+ }
743
+ /** Read ~/.claude/CLAUDE.md without following a symlink. */
744
+ function readClaudeMd(home) {
745
+ const path = join(home, ".claude", "CLAUDE.md");
746
+ const dir = lstatSync(join(home, ".claude"), { throwIfNoEntry: false });
747
+ if (!dir)
748
+ return { state: "no-claude", path, text: "", block: null };
749
+ const file = lstatSync(path, { throwIfNoEntry: false });
750
+ if (!dir.isDirectory() || (file && !file.isFile())) {
751
+ return { state: "kept", path, text: "", block: null };
752
+ }
753
+ const text = file ? readFileSync(path, "utf-8") : "";
754
+ const block = findClaudeMdBlock(text);
755
+ if (block === "broken")
756
+ return { state: "kept", path, text, block: null };
757
+ if (!block) {
758
+ return {
759
+ state: text.includes(PHARAOH_CLAUDE_MD_LINE) ? "current" : "absent",
760
+ path,
761
+ text,
762
+ block,
763
+ };
764
+ }
765
+ const current = text.slice(block.body, block.to) === `${PHARAOH_CLAUDE_MD_LINE}\n`;
766
+ return { state: current ? "current" : "stale", path, text, block };
767
+ }
768
+ /**
769
+ * Offer the Pharaoh-first line in ~/.claude/CLAUDE.md. Interactive setup only,
770
+ * never a proxy start. Asks only when the block is absent and the user hasn't
771
+ * answered before; a stale block (an earlier yes) is updated without asking.
772
+ * The line is appended inside markers, and nothing else in the file changes.
773
+ *
774
+ * @param ask - Asks the user: true for yes, false for no, null when input ended.
775
+ * @param isTerminal - Whether stdin and stderr are a terminal; false asks and writes nothing.
776
+ * @param home - Home directory override (defaults to os.homedir()).
777
+ * @returns What happened, for `describeClaudeMdOutcome`.
778
+ */
779
+ export async function offerClaudeMdLine(ask, isTerminal, home = homedir()) {
780
+ if (!isTerminal)
781
+ return "no-terminal";
782
+ const answerPath = join(home, ".pharaoh", CLAUDE_MD_ANSWER_FILE);
783
+ try {
784
+ let file = readClaudeMd(home);
785
+ // A saved answer also silences the add-it-yourself line of "kept": a no stands.
786
+ const answered = lstatSync(answerPath, { throwIfNoEntry: false }) !== undefined;
787
+ if (answered && (file.state === "absent" || file.state === "kept"))
788
+ return "answered";
789
+ if (file.state === "absent") {
790
+ const yes = await ask();
791
+ if (yes === null)
792
+ return "cancelled";
793
+ if (!yes) {
794
+ saveClaudeMdAnswer(home, answerPath, "no");
795
+ return "declined";
796
+ }
797
+ file = readClaudeMd(home); // again: it may have changed while the user answered
798
+ }
799
+ if (file.state === "absent") {
800
+ const newline = file.text !== "" && !file.text.endsWith("\n");
801
+ writeNoFollow(file.path, `${newline ? "\n" : ""}${claudeMdBlock(newline)}`, APPEND_NO_FOLLOW);
802
+ saveClaudeMdAnswer(home, answerPath, "yes");
803
+ return "added";
804
+ }
805
+ if (file.state === "stale") {
806
+ const { body, to } = file.block;
807
+ const next = `${file.text.slice(0, body)}${PHARAOH_CLAUDE_MD_LINE}\n${file.text.slice(to)}`;
808
+ writeNoFollow(file.path, next, REWRITE_NO_FOLLOW);
809
+ return "updated";
810
+ }
811
+ return file.state;
812
+ }
813
+ catch {
814
+ // ALLOWED-CATCH: setup is done; the user gets the line to add by hand, and no saved answer, so the next setup asks again
815
+ return "failed";
816
+ }
817
+ }
818
+ /** Save the user's answer (0600). Never through a symlink: `wx` refuses any existing path. */
819
+ function saveClaudeMdAnswer(home, answerPath, answer) {
820
+ try {
821
+ ensurePharaohHome(join(home, ".pharaoh"));
822
+ writeFileSync(answerPath, `${answer}\n`, { mode: 0o600, flag: "wx" });
823
+ }
824
+ catch {
825
+ // ALLOWED-CATCH: an answer that can't be saved only means the next setup asks again
826
+ }
827
+ }
828
+ /**
829
+ * The line the interactive setup prints for an `offerClaudeMdLine` outcome, or
830
+ * null when there is nothing to say (already current, already answered, no terminal).
831
+ *
832
+ * @param outcome - What `offerClaudeMdLine` returned.
833
+ */
834
+ export function describeClaudeMdOutcome(outcome) {
835
+ const byHand = `To have Claude check Pharaoh first, add this line to it yourself:\n ${PHARAOH_CLAUDE_MD_LINE}`;
836
+ switch (outcome) {
837
+ case "added":
838
+ return `Pharaoh: added the line to ~/.claude/CLAUDE.md, between pharaoh-mcp markers. npx ${PACKAGE_NAME} --clean removes it.`;
839
+ case "updated":
840
+ return "Pharaoh: updated its line in ~/.claude/CLAUDE.md.";
841
+ case "declined":
842
+ return "Pharaoh: left ~/.claude/CLAUDE.md as it is. Setup won't ask again.";
843
+ case "kept":
844
+ return `Pharaoh: left ~/.claude/CLAUDE.md as it is (it's a symlink, or its Pharaoh markers don't pair up). ${byHand}`;
845
+ case "failed":
846
+ return `Pharaoh: couldn't write ~/.claude/CLAUDE.md. ${byHand}`;
847
+ default:
848
+ return null;
849
+ }
850
+ }
851
+ /**
852
+ * Remove Pharaoh's block from ~/.claude/CLAUDE.md, taking out only bytes the
853
+ * installer wrote, and forget the answer so the next setup asks again. A file
854
+ * left empty stays: there's no telling whether the user had it before. Never
855
+ * throws: --clean and --logout go on to their other steps.
856
+ *
857
+ * @param home - Home directory override (defaults to os.homedir()).
858
+ */
859
+ function removeClaudeMdBlock(home) {
860
+ try {
861
+ const { path, text, block } = readClaudeMd(home);
862
+ if (block) {
863
+ // The newline before a start+nl marker is Pharaoh's only while the block
864
+ // ends the file; once the user writes below it, it ends their line.
865
+ const ownsNewline = block.addedNewline && block.from > 0 && block.through === text.length;
866
+ const from = ownsNewline ? block.from - 1 : block.from;
867
+ writeNoFollow(path, text.slice(0, from) + text.slice(block.through), REWRITE_NO_FOLLOW);
868
+ }
869
+ rmSync(join(home, ".pharaoh", CLAUDE_MD_ANSWER_FILE), { force: true });
870
+ }
871
+ catch (err) {
872
+ // ALLOWED-CATCH: the other cleanup steps (credentials on --logout) must still run; the user is told what's left
873
+ const code = err.code;
874
+ process.stderr.write(`Pharaoh: couldn't remove its line from ~/.claude/CLAUDE.md${code ? ` (${code})` : ""}. Delete the pharaoh-mcp block by hand.\n`);
875
+ }
876
+ }
678
877
  /**
679
878
  * Main entry point for --install-skills, also run on every proxy start.
680
879
  *
package/dist/refresh.d.ts CHANGED
@@ -48,10 +48,11 @@ export type RefreshFailureAction = "reauth" | "preserve" | "transient";
48
48
  * @param server Pharaoh server base URL (e.g. "https://mcp.pharaoh.so")
49
49
  * @param path optional custom credentials path (for tests)
50
50
  * @param timeoutMs how long the refresh request may take (for tests)
51
+ * @param strictOrigin require credentials from this origin and refuse refresh redirects
51
52
  * @returns New or adopted credentials, or a classified failure. A lock held by
52
53
  * another proxy for too long is `transient` (credentials untouched).
53
54
  */
54
- export declare function refreshIfNeeded(creds: Credentials, server: string, path?: string, timeoutMs?: number): Promise<RefreshOutcome>;
55
+ export declare function refreshIfNeeded(creds: Credentials, server: string, path?: string, timeoutMs?: number, strictOrigin?: boolean): Promise<RefreshOutcome>;
55
56
  /**
56
57
  * Read credentials from disk and refresh if the access token is expired.
57
58
  *
@@ -63,4 +64,9 @@ export declare function refreshIfNeeded(creds: Credentials, server: string, path
63
64
  * Intended use: called at proxy startup to replace the current "die if expired"
64
65
  * branch in index.ts.
65
66
  */
66
- export declare function loadAndRefresh(server: string, path?: string): Promise<RefreshOutcome | null>;
67
+ export declare function loadAndRefresh(server: string, path?: string, strictOrigin?: boolean): Promise<RefreshOutcome | null>;
68
+ /** Refresh for a client that accepts credentials only from its configured origin. */
69
+ export declare function originBoundRefresh(server: string, path?: string): {
70
+ load: () => Promise<RefreshOutcome | null>;
71
+ renew: (creds: Credentials) => Promise<RefreshOutcome>;
72
+ };
package/dist/refresh.js CHANGED
@@ -41,10 +41,11 @@ const REFRESH_TIMEOUT_MS = 10_000;
41
41
  * @param server Pharaoh server base URL (e.g. "https://mcp.pharaoh.so")
42
42
  * @param path optional custom credentials path (for tests)
43
43
  * @param timeoutMs how long the refresh request may take (for tests)
44
+ * @param strictOrigin require credentials from this origin and refuse refresh redirects
44
45
  * @returns New or adopted credentials, or a classified failure. A lock held by
45
46
  * another proxy for too long is `transient` (credentials untouched).
46
47
  */
47
- export async function refreshIfNeeded(creds, server, path, timeoutMs = REFRESH_TIMEOUT_MS) {
48
+ export async function refreshIfNeeded(creds, server, path, timeoutMs = REFRESH_TIMEOUT_MS, strictOrigin = false) {
48
49
  let release;
49
50
  try {
50
51
  release = await acquireCredentialsLock(path);
@@ -58,11 +59,13 @@ export async function refreshIfNeeded(creds, server, path, timeoutMs = REFRESH_T
58
59
  }
59
60
  try {
60
61
  const current = readCredentials(path);
62
+ const source = current ?? creds;
63
+ if (strictOrigin && !matchesOrigin(source, server))
64
+ return originMismatch();
61
65
  if (current && current.access_token !== creds.access_token && !isExpired(current)) {
62
66
  return { ok: true, creds: current };
63
67
  }
64
- const source = current ?? creds;
65
- const outcome = await requestRefresh(source, server, timeoutMs, path);
68
+ const outcome = await requestRefresh(source, server, timeoutMs, path, strictOrigin);
66
69
  if (outcome.ok || outcome.action !== "reauth")
67
70
  return outcome;
68
71
  const after = readCredentials(path);
@@ -70,6 +73,8 @@ export async function refreshIfNeeded(creds, server, path, timeoutMs = REFRESH_T
70
73
  deleteCredentials(path);
71
74
  return outcome;
72
75
  }
76
+ if (strictOrigin && !matchesOrigin(after, server))
77
+ return originMismatch();
73
78
  return isExpired(after) ? outcome : { ok: true, creds: after };
74
79
  }
75
80
  finally {
@@ -80,7 +85,7 @@ export async function refreshIfNeeded(creds, server, path, timeoutMs = REFRESH_T
80
85
  * One refresh-grant round trip: POST the refresh token, persist and return the
81
86
  * new credentials on success, or classify the failure. Caller holds the lock.
82
87
  */
83
- async function requestRefresh(creds, server, timeoutMs, path) {
88
+ async function requestRefresh(creds, server, timeoutMs, path, strictOrigin = false) {
84
89
  // Guard: refresh token itself expired. No point hitting the server — the
85
90
  // grant will fail with invalid_grant. Short-circuit to reauth.
86
91
  if (isRefreshExpired(creds)) {
@@ -101,6 +106,7 @@ async function requestRefresh(creds, server, timeoutMs, path) {
101
106
  body: JSON.stringify({
102
107
  refresh_token: creds.refresh_token,
103
108
  }),
109
+ redirect: strictOrigin ? "error" : undefined,
104
110
  signal: AbortSignal.timeout(timeoutMs),
105
111
  });
106
112
  }
@@ -192,15 +198,36 @@ async function requestRefresh(creds, server, timeoutMs, path) {
192
198
  * Intended use: called at proxy startup to replace the current "die if expired"
193
199
  * branch in index.ts.
194
200
  */
195
- export async function loadAndRefresh(server, path) {
201
+ export async function loadAndRefresh(server, path, strictOrigin = false) {
196
202
  const creds = readCredentials(path);
197
203
  if (!creds)
198
204
  return null;
205
+ if (strictOrigin && !matchesOrigin(creds, server))
206
+ return originMismatch();
199
207
  // Access token still valid — no refresh needed.
200
208
  const expiresAt = new Date(creds.expires_at).getTime();
201
209
  const bufferMs = creds.expires_in * 1000 * 0.1;
202
210
  if (Date.now() < expiresAt - bufferMs) {
203
211
  return { ok: true, creds };
204
212
  }
205
- return refreshIfNeeded(creds, server, path);
213
+ return refreshIfNeeded(creds, server, path, undefined, strictOrigin);
214
+ }
215
+ /** A fixed-origin caller must never send credentials issued by another server. */
216
+ function matchesOrigin(creds, server) {
217
+ try {
218
+ return new URL(creds.sse_url).origin === new URL(server).origin;
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ }
224
+ function originMismatch() {
225
+ return { ok: false, action: "transient", message: "Credentials belong to a different server" };
226
+ }
227
+ /** Refresh for a client that accepts credentials only from its configured origin. */
228
+ export function originBoundRefresh(server, path) {
229
+ return {
230
+ load: () => loadAndRefresh(server, path, true),
231
+ renew: (creds) => refreshIfNeeded(creds, server, path, undefined, true),
232
+ };
206
233
  }
package/dist/setup.d.ts CHANGED
@@ -54,3 +54,8 @@ export declare function runSetup(options?: {
54
54
  home?: string;
55
55
  claude?: ClaudeCli;
56
56
  }): boolean;
57
+ /** Detect the local Codex CLI before touching any settings. */
58
+ export declare function hasCodex(): boolean;
59
+ /** Preserve custom registrations and any existing disabled setting. */
60
+ export declare function runCodexSetup(): boolean;
61
+ export declare function cleanCodexSetup(): void;
package/dist/setup.js CHANGED
@@ -185,3 +185,69 @@ export function runSetup(options) {
185
185
  printLines(`Pharaoh: registered as global MCP server (scope: user) running ${spec}`);
186
186
  return true;
187
187
  }
188
+ /** Detect the local Codex CLI before touching any settings. */
189
+ export function hasCodex() {
190
+ try {
191
+ execFileSync("codex", ["--version"], { stdio: "pipe", timeout: 10_000 });
192
+ return true;
193
+ }
194
+ catch {
195
+ return false;
196
+ }
197
+ }
198
+ function codexMcp(args) {
199
+ return execFileSync("codex", ["mcp", ...args], {
200
+ encoding: "utf8",
201
+ stdio: ["ignore", "pipe", "pipe"],
202
+ timeout: 30_000,
203
+ });
204
+ }
205
+ function configuredCodexPharaoh() {
206
+ try {
207
+ const { transport, enabled } = JSON.parse(codexMcp(["get", "pharaoh", "--json"]));
208
+ return {
209
+ owned: transport?.type === "stdio" &&
210
+ transport.command === "npx" &&
211
+ transport.args?.length === 1 &&
212
+ /^@pharaoh-so\/mcp@\d+\.\d+\.\d+$/.test(transport.args[0]) &&
213
+ !transport.env &&
214
+ !transport.env_vars?.length &&
215
+ !transport.cwd,
216
+ enabled,
217
+ };
218
+ }
219
+ catch (error) {
220
+ if (error &&
221
+ typeof error === "object" &&
222
+ "stderr" in error &&
223
+ typeof error.stderr === "string" &&
224
+ error.stderr.includes("No MCP server named 'pharaoh' found."))
225
+ return null;
226
+ throw error;
227
+ }
228
+ }
229
+ /** Preserve custom registrations and any existing disabled setting. */
230
+ export function runCodexSetup() {
231
+ try {
232
+ const existing = configuredCodexPharaoh();
233
+ if (existing && (!existing.owned || existing.enabled === false))
234
+ return true;
235
+ codexMcp(["add", "pharaoh", "--", "npx", pinnedPackageSpec()]);
236
+ return true;
237
+ }
238
+ catch {
239
+ printLines("Pharaoh: Codex setup could not update its MCP registration.");
240
+ return false;
241
+ }
242
+ }
243
+ export function cleanCodexSetup() {
244
+ if (!hasCodex())
245
+ return;
246
+ try {
247
+ if (configuredCodexPharaoh()?.owned)
248
+ codexMcp(["remove", "pharaoh"]);
249
+ }
250
+ catch {
251
+ printLines("Pharaoh: Codex setup could not remove its MCP registration.");
252
+ }
253
+ }