@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.
- package/.mcp.json +1 -1
- package/CHANGELOG.md +41 -0
- package/LICENSE +1 -1
- package/NOTICE +28 -0
- package/README.md +5 -5
- package/commands/plan.md +11 -11
- package/commands/review.md +2 -2
- package/dist/auth.d.ts +5 -0
- package/dist/credentials.d.ts +8 -0
- package/dist/gateway.js +1 -1
- package/dist/helpers.d.ts +15 -5
- package/dist/helpers.js +71 -2
- package/dist/index.js +51 -26
- package/dist/install-skills.d.ts +30 -5
- package/dist/install-skills.js +210 -11
- package/dist/refresh.d.ts +8 -2
- package/dist/refresh.js +33 -6
- package/dist/setup.d.ts +5 -0
- package/dist/setup.js +66 -0
- package/inspect-tools.json +89 -36
- package/{skills → npx-skills}/brainstorm/SKILL.md +2 -1
- package/{skills → npx-skills}/debug/SKILL.md +2 -1
- package/{skills → npx-skills}/pharaoh/SKILL.md +13 -6
- package/{skills → npx-skills}/plan/SKILL.md +13 -14
- package/npx-skills/refactor/SKILL.md +38 -0
- package/{skills → npx-skills}/review/SKILL.md +4 -5
- package/{skills → npx-skills}/tdd/SKILL.md +2 -1
- package/{skills → npx-skills}/verify/SKILL.md +2 -1
- package/package.json +6 -4
- package/project-memory/bundle.mjs +137 -0
- package/project-memory/execute-turn.mjs +240 -0
- package/project-memory/hook-config.mjs +91 -0
- package/project-memory/hook-events.mjs +141 -0
- package/project-memory/local.mjs +463 -0
- package/project-memory/memory-api.mjs +182 -0
- package/project-memory/pharaoh-memory-hook.mjs +33 -0
- package/project-memory/pharaoh-memory.mjs +55 -0
- package/project-memory/setup.d.mts +14 -0
- package/project-memory/setup.mjs +151 -0
- package/project-memory/user-target.mjs +221 -0
- package/trace-app.html +222 -101
- package/skills/.gitkeep +0 -0
- package/skills/refactor/SKILL.md +0 -34
package/dist/install-skills.js
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
610
|
-
*
|
|
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)
|
|
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.
|
|
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
|
|
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
|
|
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
|
+
}
|