Skip to content

Commit e5bd092

Browse files
committed
Hold the add until the plugin's access check passes
1 parent 2b597ad commit e5bd092

3 files changed

Lines changed: 131 additions & 6 deletions

File tree

‎packages/plugins/mcp/src/react/CodexPluginAdd.tsx‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
1010
import { addIntegrationErrorMessage } from "@executor-js/react/lib/integration-add";
1111

1212
import { addMcpServer, checkCodexPluginAccess, codexPluginsAtom } from "./atoms";
13+
import { accessBlocked, accessPending, type CodexAccessState } from "./codex-access-gate";
1314
import { CODEX_PERMISSIONS } from "../sdk/codex-permissions";
1415

1516
// ---------------------------------------------------------------------------
@@ -31,7 +32,7 @@ export default function CodexPluginAdd(props: {
3132
const doCheckAccess = useAtomSet(checkCodexPluginAccess, { mode: "promiseExit" });
3233

3334
const [adding, setAdding] = useState(false);
34-
const [access, setAccess] = useState<{ status: string; message?: string } | null>(null);
35+
const [access, setAccess] = useState<CodexAccessState | null>(null);
3536
const [checking, setChecking] = useState(false);
3637
const [error, setError] = useState<string | null>(null);
3738

@@ -46,6 +47,17 @@ export default function CodexPluginAdd(props: {
4647
AsyncResult.isSuccess(integrationsResult) &&
4748
integrationsResult.value.some((integration) => String(integration.slug) === plugin.slug);
4849

50+
// Adding is held back until the probe says the plugin can actually run: an
51+
// integration added while macOS is blocking it looks connected and fails on
52+
// its first real call, by which point the person has left the one card that
53+
// explains the fix.
54+
const blocked = accessBlocked(access);
55+
const pending = accessPending({
56+
checking,
57+
declaresPermissions: permissions.length > 0,
58+
access,
59+
});
60+
4961
const handleAdd = async () => {
5062
if (plugin === undefined) return;
5163
setAdding(true);
@@ -101,7 +113,7 @@ export default function CodexPluginAdd(props: {
101113
const exit = await doCheckAccess({ params: { id: props.presetId }, reactivityKeys: [] });
102114
setAccess(
103115
Exit.isSuccess(exit)
104-
? (exit.value as { status: string; message?: string })
116+
? (exit.value as CodexAccessState)
105117
: { status: "blocked", message: "Could not reach the plugin." },
106118
);
107119
setChecking(false);
@@ -200,8 +212,9 @@ export default function CodexPluginAdd(props: {
200212
macOS access
201213
</span>
202214
<p className="text-[12px] text-muted-foreground">
203-
macOS asks the first time this runs. If you miss the prompt, it will not ask again —
204-
enable it here.
215+
{blocked
216+
? "This has to be on before the plugin can be added — turn it on below, then check again."
217+
: "macOS asks the first time this runs. If you miss the prompt, it will not ask again — enable it here."}
205218
</p>
206219
<div className="flex items-center gap-3">
207220
<span
@@ -243,6 +256,19 @@ export default function CodexPluginAdd(props: {
243256
</div>
244257
)}
245258

259+
{/* A block on a plugin that declares no macOS access has no panel above
260+
to carry it, so state the reason next to the action it is holding. */}
261+
{blocked && permissions.length === 0 && (
262+
<div className="flex items-center gap-3">
263+
<p className="text-[12px] text-destructive">
264+
{access?.message ?? "This plugin cannot run yet."}
265+
</p>
266+
<Button type="button" variant="secondary" onClick={() => void handleCheck()}>
267+
Check again
268+
</Button>
269+
</div>
270+
)}
271+
246272
{error !== null && <p className="text-[12px] text-destructive">{error}</p>}
247273

248274
<FloatActions>
@@ -254,8 +280,13 @@ export default function CodexPluginAdd(props: {
254280
View integration
255281
</Button>
256282
) : plugin.available ? (
257-
<Button type="button" onClick={() => void handleAdd()} loading={adding}>
258-
Add integration
283+
<Button
284+
type="button"
285+
onClick={() => void handleAdd()}
286+
loading={adding}
287+
disabled={blocked || pending}
288+
>
289+
{pending ? "Checking access…" : "Add integration"}
259290
</Button>
260291
) : (
261292
<Button asChild>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
3+
import { accessBlocked, accessPending } from "./codex-access-gate";
4+
5+
describe("accessBlocked", () => {
6+
it("holds the add only on a refusal", () => {
7+
expect(accessBlocked({ status: "blocked", message: "macOS blocked this" })).toBe(true);
8+
});
9+
10+
it("lets through every answer that is not a refusal", () => {
11+
// `unsupported` and `nothing-to-check` are answers about the HOST and the
12+
// PLUGIN, not about permission. Treating either as a block would strand a
13+
// plugin that needs no grant at all.
14+
for (const status of ["ok", "unsupported", "nothing-to-check", "unknown", "not-installed"]) {
15+
expect(accessBlocked({ status })).toBe(false);
16+
}
17+
});
18+
19+
it("does not hold before an answer exists", () => {
20+
// `accessPending` owns that window; a card without permissions has no
21+
// probe to wait for and must not be blocked by its own silence.
22+
expect(accessBlocked(null)).toBe(false);
23+
});
24+
});
25+
26+
describe("accessPending", () => {
27+
it("waits while the probe runs", () => {
28+
expect(accessPending({ checking: true, declaresPermissions: true, access: null })).toBe(true);
29+
// Still pending on a re-check, even though a previous answer is in hand:
30+
// that answer is what the user just acted on.
31+
expect(
32+
accessPending({ checking: true, declaresPermissions: true, access: { status: "blocked" } }),
33+
).toBe(true);
34+
});
35+
36+
it("waits for the first answer on a plugin that declares permissions", () => {
37+
expect(accessPending({ checking: false, declaresPermissions: true, access: null })).toBe(true);
38+
});
39+
40+
it("does not wait once an answer has arrived", () => {
41+
expect(
42+
accessPending({ checking: false, declaresPermissions: true, access: { status: "ok" } }),
43+
).toBe(false);
44+
});
45+
46+
it("does not wait when nothing will be checked", () => {
47+
expect(accessPending({ checking: false, declaresPermissions: false, access: null })).toBe(
48+
false,
49+
);
50+
});
51+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// ---------------------------------------------------------------------------
2+
// When the Codex plugin card may hand over its Add button.
3+
//
4+
// The probe behind this is the only way to know whether macOS will let the
5+
// plugin run: privacy decisions are not readable, so the card tries the real
6+
// path and reads the answer. Adding before that answer arrives produces an
7+
// integration that looks connected and fails on its first call, at a point
8+
// where the explanation — and the switch that fixes it — are no longer on
9+
// screen.
10+
// ---------------------------------------------------------------------------
11+
12+
/** The `checkCodexPluginAccess` result, as the card holds it. */
13+
export interface CodexAccessState {
14+
readonly status: string;
15+
readonly message?: string;
16+
}
17+
18+
/**
19+
* The plugin was reached and refused.
20+
*
21+
* Only `blocked` counts. `unsupported` (a host that cannot spawn stdio at all)
22+
* and `nothing-to-check` (a plugin with no probe) are answers, not blocks, and
23+
* holding the button on either would strand plugins that need no permission.
24+
*/
25+
export const accessBlocked = (access: CodexAccessState | null): boolean =>
26+
access !== null && access.status === "blocked";
27+
28+
/**
29+
* The answer is still coming.
30+
*
31+
* A card that declares permissions runs the probe on open, so `null` there
32+
* means the check has not reported yet rather than that nothing will check.
33+
* Without permissions there is nothing to wait for, and the button stays live.
34+
*/
35+
export const accessPending = ({
36+
checking,
37+
declaresPermissions,
38+
access,
39+
}: {
40+
readonly checking: boolean;
41+
readonly declaresPermissions: boolean;
42+
readonly access: CodexAccessState | null;
43+
}): boolean => checking || (declaresPermissions && access === null);

0 commit comments

Comments
 (0)