@miadi/connector 0.1.19 → 0.1.20

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/README.md CHANGED
@@ -62,7 +62,9 @@ A mission is found by the issue it implements (jgwill/Miadi#913). `request_autho
62
62
 
63
63
  `request_session_steer` sends one authorized follow-up to one existing live agent, within the steering grant an approved run carries (`steering: { max_attempts, expires_at, pane }` on `request_authorized_run`). It peeks at the bound pane — the grant's pane or the session's first pane, never whichever pane is active — refuses a busy agent or a pane that is not an agent, types the exact instruction literally, and re-peeks to verify the agent accepted it (`accepted`) or only holds it at the prompt (`submitted`). Every attempt leaves a durable artifact with the instruction's sha256, its grant, its target and both captures. When the factory recorded the mission's pane, the steer goes to that pane id and nowhere else: another session is `stale_binding`, another pane `wrong_pane`, a replaced pane `stale_binding`, and text already at the prompt `composing` (nothing typed, Enter not pressed). A pane whose prompt box cannot be read (any TUI but Claude Code's today) is `blocked`. Every tmux address is exact (`=name:w.p`), because tmux otherwise matches a session by the start of its name. Only a steer that typed its instruction uses one of the grant's attempts (`typed`, and `attempts` in the answer). The connector's service steers through the tmux server of the user it runs as; `get_capabilities.steering` says whether that transport answers now, and while it does not, `request_session_steer` records nothing and uses no attempt. A steer the grant does not cover waits for one explicit scoped human decision on its `approval_url`, which runs exactly that instruction once.
64
64
 
65
- `request_authorized_run` may name a `provider` (`codex`, `pi`, `copilot`, `claude`). The connector verifies it is installed on the host before recording anything; an unknown or unavailable provider answers `provider_unavailable` and the mission stays unstarted. With no provider named, the first installed provider in the preference order is resolved and named in the brief; Claude Code is never chosen silently — it runs only when named. `provider_capabilities` (write tier) reports what the host has installed, the sign-in evidence, the preference order, and the read-only Jev check: `unconfigured` without `JEV_AI_API_KEY` or `TYPESAFE_API_KEY` in the connector's environment, else the models Jev lists for the key (no charge). A provider name inside a task's text selects nothing.
65
+ `request_authorized_run` may name a `provider` (`codex`, `pi`, `copilot`, `claude`). The connector verifies it is installed on the host before recording anything; an unknown or unavailable provider answers `provider_unavailable` and the mission stays unstarted. With no provider named, the first installed provider in the preference order that may be picked by default (Pi, then Copilot) is resolved and named in the brief; Codex and Claude Code are never chosen silently, each runs only when named. `provider_capabilities` (write tier) reports what the host has installed, the sign-in evidence, the preference order, and the read-only Jev check: `unconfigured` without `JEV_AI_API_KEY` or `TYPESAFE_API_KEY` in the connector's environment, else the models Jev lists for the key (no charge). A provider name inside a task's text selects nothing.
66
+
67
+ **Codex runs only on its requester's own choice (jgwill/Miadi#926).** The provider field is the caller's: the connector cannot read the chat, so `provider: "codex"` chooses nothing for the person. A run that names Codex is recorded, and its answer says it `needs_person_choice`. The approval page then shows no Approve button. Only the requester, signed in there with their own Miadi person token, can tick and send "Choose codex for this mission". That `provider_choice` record is bound to the request id, its version and its content hash, must be under 24 hours old when the request is approved, and covers that one mission. A revision makes it stale. A generic Approve, on the page or on a card, is refused as `provider_choice_required`, and so is a choice by any other person or by a service token. The requester can instead set their own policy at `/providers` so their requests may name Codex without a per-mission choice; that policy is theirs only and revisable. The check runs at request, revision, approval, and where the brief is written (approval and every rerun). The brief carries `provider_authorization` and `provider_choice` (or `provider_policy`), and the factory runner refuses to launch Codex for a connector brief without them. `request_authorized_run`, `revise_pending_request`, `mission_status`, `show_decisions` and `mission_model_preflight` show `provider_authorization`; after approval it is also in the request's `completion_receipt`, which `get_run_result` returns. A Codex launch without it is named on the mission and fails the preflight closed as `provider_not_authorized`. A brief the runner refuses goes to `queue/refused/` with its reason, and the request shows it. Other providers keep their regular policy, and nothing falls back to Codex. What this does not cover: anyone who can write `/srv/miadi/factory/queue` or the connector's state directory can forge a brief or a record (the folder's permissions are the boundary); a configured launcher that runs Codex behind a wrapper whose name does not say codex is trusted as configured; a brief to an existing session reaches only an idle Claude Code agent today, so it is not checked; and an agent that starts `codex` itself from inside its session is outside the connector's reach.
66
68
 
67
69
  `mission_model_preflight` (write tier) answers, for one mission, which provider and model fit its task type and criticality, and how that is known: contract `miadi.mission_model_preflight` v1, candidates with their configured default model, sign-in evidence (config directory only), quota `unknown`, policy eligibility, a review floor, and a recommendation or an abstention with reasons. Its `decider.source` is `static_fallback`, `jev` or `synthetic_fixture`. Jev is asked only with `consult_jev: true` and a key on the host; it is sent task type, criticality and candidates, never task text, mission id or repository, and its answer is advisory: uncalibrated, below 0.8 or outside the contract, the preflight abstains. A reported `launch` on another provider than the approved one answers `provider_mismatch` before anything is asked. It authorizes nothing. `bench/model-preflight-bench.mjs` compares the 0.1.12 baseline, the static preflight and Jev on synthetic scenarios; `bench/results/` keeps the runs.
68
70
 
package/dist/approve.d.ts CHANGED
@@ -17,6 +17,7 @@ import { type MiadiPerson } from "./people.js";
17
17
  import { type ConnectorRecord } from "./records.js";
18
18
  import { type RequestProjection } from "./request-state.js";
19
19
  import { type TmuxLike } from "./steer.js";
20
+ import { type ProviderAuthorization } from "./provider-policy.js";
20
21
  export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
21
22
  export interface ApprovalDeps {
22
23
  config: ConnectorConfig;
@@ -55,7 +56,7 @@ export declare const DECISIONS: readonly ["approve", "approve_with_changes", "re
55
56
  export type Decision = (typeof DECISIONS)[number];
56
57
  /** The task a work proposal asks for, written from the proposal's own fields. */
57
58
  export declare function proposalTask(record: ConnectorRecord): string;
58
- export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson, sessions: string[], instructions?: string): {
59
+ export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson, sessions: string[], instructions?: string, providerAttrs?: Record<string, string>): {
59
60
  to: string;
60
61
  brief: string;
61
62
  };
@@ -83,6 +84,14 @@ export declare class StaleVersion extends AlreadyDecided {
83
84
  readonly decidedVersion: number;
84
85
  constructor(currentVersion: number, decidedVersion: number);
85
86
  }
87
+ /**
88
+ * An approval of a run whose provider needs the requester's own choice for this mission,
89
+ * and has none (jgwill/Miadi#926). Declining or sending it back stays possible.
90
+ */
91
+ export declare class ProviderChoiceRequired extends Error {
92
+ readonly authorization: ProviderAuthorization;
93
+ constructor(authorization: ProviderAuthorization);
94
+ }
86
95
  /** What a decision answers: the state it left, its receipt, and `replayed` when it repeats a decision already taken. */
87
96
  export interface Decided {
88
97
  decision: NonNullable<ConnectorRecord["approval_state"]>;
@@ -135,7 +144,7 @@ export declare function rerun(r: ConnectorRecord, person: MiadiPerson, deps: App
135
144
  }>;
136
145
  export declare function isApprovable(r: ConnectorRecord | undefined): r is ConnectorRecord;
137
146
  /** What approving a request does, in one sentence, for the card a person decides on. */
138
- export declare function consequenceOf(r: ConnectorRecord): string;
147
+ export declare function consequenceOf(r: ConnectorRecord, authorization?: ProviderAuthorization): string;
139
148
  export declare function registerApprovalRoutes(app: Express, deps: ApprovalDeps): void;
140
149
  /** The request, and the decision form for a write-tier person (their token carried in the form they just signed into). */
141
150
  /**
@@ -146,5 +155,7 @@ export declare function requestProjection(record: ConnectorRecord, opts?: {
146
155
  reviewBase?: string;
147
156
  factoryDir?: string;
148
157
  }): RequestProjection | null;
158
+ /** The person's own provider policy page: read and change it, signed in with their person token (jgwill/Miadi#926). */
159
+ export declare function registerProviderPolicyRoutes(app: Express, deps: Pick<ApprovalDeps, "ledger" | "identify" | "log">): void;
149
160
  /** How a request is named to a person. */
150
161
  export declare const KIND_LABELS: Record<string, string>;
package/dist/approve.js CHANGED
@@ -24,6 +24,8 @@ import { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
24
24
  import { factoryLedgerPath, readFactoryReceipts, receiptsForRecord } from "./factory-receipt.js";
25
25
  import { steerAttempts, steerSession, tmuxAdapter } from "./steer.js";
26
26
  import { contentHash, versionOf } from "./revisions.js";
27
+ import { canReadReading } from "./reading/privacy.js";
28
+ import { authorizationReceipt, authorizeProvider, briefAttributes, CHOICE_TTL_MS, CODEX_POLICIES, isPersonId, needsPersonChoice, providerPolicyOf, } from "./provider-policy.js";
27
29
  export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
28
30
  /**
29
31
  * One line per step of a decision, for the journal: the request, a correlation id,
@@ -103,8 +105,10 @@ export function proposalTask(record) {
103
105
  }
104
106
  return lines.join("\n");
105
107
  }
106
- export function runBrief(record, approver, sessions, instructions) {
108
+ export function runBrief(record, approver, sessions, instructions, providerAttrs = {}) {
107
109
  const body = (record.kind === "work_proposal" ? { task: proposalTask(record) } : record.body);
110
+ // A request recorded before providers were resolved (jgwill/Miadi#893) names only what was asked (#926 review RR7).
111
+ const provider = body.resolved_provider ?? body.provider;
108
112
  const short = record.id.split(":")[1]?.slice(0, 8) ?? "run";
109
113
  const to = body.session && sessions.includes(body.session) ? body.session : `new:${body.session ?? `connector-${yymmdd()}-${short}`}`;
110
114
  // A new session opens where the request says, else where the factory runner's config says (defaultCwd): no host path here (#911).
@@ -116,7 +120,9 @@ export function runBrief(record, approver, sessions, instructions) {
116
120
  `record="${attr(record.id)}"`,
117
121
  `version="${versionOf(record)}"`,
118
122
  // The provider the connector verified at request time (jgwill/Miadi#893); the intake may honour it or record why not.
119
- ...(body.resolved_provider ? [`provider="${attr(body.resolved_provider)}"`] : []),
123
+ ...(provider ? [`provider="${attr(provider)}"`] : []),
124
+ // What authorizes a provider that needs the requester's own choice; the runner refuses Codex without it (jgwill/Miadi#926).
125
+ ...Object.entries(providerAttrs).map(([k, v]) => `${k}="${attr(v)}"`),
120
126
  ...(to.startsWith("new:") && cwd ? [`cwd="${attr(cwd)}"`] : []),
121
127
  ];
122
128
  const lines = [
@@ -189,6 +195,11 @@ export async function executeApproved(record, approver, deps, instructions) {
189
195
  return { status: res.status, ok: res.ok, response: parsed };
190
196
  };
191
197
  const queueBrief = () => {
198
+ // Checked again where the brief is written: an approval, and every rerun of it (jgwill/Miadi#926).
199
+ const authorization = record.kind === "run_request" ? authorizeProvider(record, deps.ledger.all()) : undefined;
200
+ if (authorization && !authorization.authorized) {
201
+ return { outcome: "failed", provider_authorization: authorizationReceipt(authorization), message: `No brief was written. ${authorization.explanation}` };
202
+ }
192
203
  const queue = join(deps.factoryDir ?? factoryDir(), "queue");
193
204
  mkdirSync(queue, { recursive: true });
194
205
  // A lister that cannot answer means no session is known live: the brief opens a new one (#922 R16).
@@ -199,10 +210,16 @@ export async function executeApproved(record, approver, deps, instructions) {
199
210
  catch {
200
211
  sessions = [];
201
212
  }
202
- const { to, brief } = runBrief(record, approver, sessions, instructions);
213
+ const { to, brief } = runBrief(record, approver, sessions, instructions, authorization ? briefAttributes(authorization) : {});
203
214
  const file = runBriefFile(record);
204
215
  writeFileSync(join(queue, file), brief, { flag: "wx", mode: 0o640 });
205
- return { outcome: "queued", to, file, note: "The factory intake delivers queued briefs every 5 minutes." };
216
+ return {
217
+ outcome: "queued",
218
+ to,
219
+ file,
220
+ ...(authorization ? { provider_authorization: authorizationReceipt(authorization) } : {}),
221
+ note: "The factory intake delivers queued briefs every 5 minutes.",
222
+ };
206
223
  };
207
224
  switch (record.kind) {
208
225
  case "run_request":
@@ -357,6 +374,17 @@ export class StaleVersion extends AlreadyDecided {
357
374
  this.decidedVersion = decidedVersion;
358
375
  }
359
376
  }
377
+ /**
378
+ * An approval of a run whose provider needs the requester's own choice for this mission,
379
+ * and has none (jgwill/Miadi#926). Declining or sending it back stays possible.
380
+ */
381
+ export class ProviderChoiceRequired extends Error {
382
+ authorization;
383
+ constructor(authorization) {
384
+ super(authorization.explanation);
385
+ this.authorization = authorization;
386
+ }
387
+ }
360
388
  /** The decisions being recorded now, by request id: one at a time per request, in this process. */
361
389
  const deciding = new Map();
362
390
  /** Whether a decision on this request is being recorded now, its run may still be going. */
@@ -414,6 +442,14 @@ export async function decide(r, person, choice, deps, opts) {
414
442
  }
415
443
  if (versionOf(live) !== decidedVersion)
416
444
  throw new StaleVersion(versionOf(live), decidedVersion);
445
+ // A generic approval never chooses Codex: the requester's own fresh choice or policy must already stand (jgwill/Miadi#926).
446
+ if (live.kind === "run_request" && approvalStateOf(choice) === "approved") {
447
+ const authorization = authorizeProvider(live, deps.ledger.all(), { requireFresh: true });
448
+ if (!authorization.authorized) {
449
+ logDecision(deps, { phase: "provider_choice_required", record: r.id, via: opts.via, choice, provider: authorization.provider });
450
+ throw new ProviderChoiceRequired(authorization);
451
+ }
452
+ }
417
453
  const entry = { ...asked, correlation: randomUUID(), started: Date.now() };
418
454
  deciding.set(r.id, entry);
419
455
  entry.promise = decideOnce(live, person, choice, deps, { ...opts, correlation: entry.correlation, started: entry.started });
@@ -602,11 +638,13 @@ export function isApprovable(r) {
602
638
  return Boolean(r && APPROVABLE.has(r.kind));
603
639
  }
604
640
  /** What approving a request does, in one sentence, for the card a person decides on. */
605
- export function consequenceOf(r) {
641
+ export function consequenceOf(r, authorization) {
606
642
  const b = r.body;
607
643
  switch (r.kind) {
608
644
  case "run_request":
609
- return "Approving queues a brief for the factory intake, which opens or reaches an agent session within minutes.";
645
+ return authorization && !authorization.authorized
646
+ ? `Approving is refused until ${r.actor.name} chooses ${authorization.provider} for this mission on its approval page; approving does not choose it. Declining or sending it back stays possible.`
647
+ : `Approving queues a brief for the factory intake, which opens or reaches an agent session within minutes${authorization?.provider ? ` with ${authorization.provider}` : ""}.`;
610
648
  case "work_proposal":
611
649
  return "Approving starts the work: a brief written from the proposal and your instructions is queued for an agent session.";
612
650
  case "review_operation_request":
@@ -658,8 +696,43 @@ export function registerApprovalRoutes(app, deps) {
658
696
  if (!writer && r.actor.person_id !== person.id) {
659
697
  return void res.status(403).type("html").send(signInToRead(r, `Reading and deciding this request needs Miadi's write tier; ${person.name} has ${person.api_tier ?? "no API tier"}.`));
660
698
  }
699
+ const requester = r.actor.person_id === person.id && isPersonId(person.id);
661
700
  if (!body.decision)
662
- return void res.type("html").send(approvePage(deps, r, { token: writer ? token : undefined }));
701
+ return void res.type("html").send(approvePage(deps, r, { token: writer ? token : undefined, chooserToken: requester ? token : undefined }));
702
+ if (body.decision === "choose_provider") {
703
+ // The requester's own, separate choice of a provider that needs one, for this version only (jgwill/Miadi#926).
704
+ const live = find(r.id);
705
+ const again = (status, error) => void res.status(status).type("html").send(approvePage(deps, live, { token: writer ? token : undefined, chooserToken: requester ? token : undefined, error }));
706
+ if (!requester) {
707
+ return again(403, isPersonId(r.actor.person_id)
708
+ ? `Only ${r.actor.name}, who asked, can choose a provider for their own mission.`
709
+ : `${r.actor.name} is a service token: only a person chooses a provider, for a request of their own. Name another provider for this request.`);
710
+ }
711
+ if (live.approval_state !== "awaiting_approval" || isBeingDecided(live.id))
712
+ return again(409, "A provider is chosen only while the request awaits a decision.");
713
+ const provider = String(body.provider ?? "");
714
+ const named = authorizeProvider(live, deps.ledger.all(), { requireFresh: true });
715
+ if (!needsPersonChoice(provider) || provider !== named.provider)
716
+ return again(400, `This request names ${named.provider ?? "no provider"}; only that provider can be chosen here.`);
717
+ if (Number(body.version) !== versionOf(live))
718
+ return again(409, `This request is now at version ${versionOf(live)}. Read it and choose again.`);
719
+ if (body.confirm !== "yes")
720
+ return again(400, `Tick the box to choose ${provider} for this mission.`);
721
+ if (!named.authorized || named.state !== "person_choice") {
722
+ deps.ledger.append({
723
+ id: newRecordId("provider_choice"),
724
+ kind: "provider_choice",
725
+ created_at: new Date().toISOString(),
726
+ actor: { person_id: person.id, name: person.name, client_id: "approval-page" },
727
+ refs: [live.id],
728
+ evidence_refs: [],
729
+ claim_category: "observed",
730
+ body: { request_id: live.id, provider, version: versionOf(live), content_hash: contentHash(live) },
731
+ });
732
+ logDecision(deps, { phase: "provider_chosen", record: live.id, provider, version: versionOf(live) });
733
+ }
734
+ return void res.type("html").send(approvePage(deps, find(r.id), { token: writer ? token : undefined, chooserToken: token }));
735
+ }
663
736
  if (!writer)
664
737
  return void res.status(403).type("html").send(approvePage(deps, r, { error: `Deciding needs Miadi's write tier; ${person.name} has ${person.api_tier ?? "no API tier"}.` }));
665
738
  if (body.decision === "rerun") {
@@ -705,6 +778,9 @@ export function registerApprovalRoutes(app, deps) {
705
778
  decided = settled;
706
779
  }
707
780
  catch (error) {
781
+ if (error instanceof ProviderChoiceRequired) {
782
+ return void res.status(409).type("html").send(approvePage(deps, find(r.id), { token, chooserToken: requester ? token : undefined, error: error.message }));
783
+ }
708
784
  if (error instanceof StaleVersion)
709
785
  return void res.status(409).type("html").send(approvePage(deps, find(r.id), { token, error: error.message }));
710
786
  if (error instanceof AlreadyDecided)
@@ -746,6 +822,9 @@ export function registerApprovalRoutes(app, deps) {
746
822
  const r = find(String(req.params.id));
747
823
  if (!r)
748
824
  return void res.status(404).type("html").send(page("Not found", "<h1>No record with that id</h1>"));
825
+ if (!canReadReading(r, current(deps.ledger.all()), person)) {
826
+ return void res.status(403).type("html").send(page("Miadi record", recordForm(String(req.params.id), "This source and its reading are private to the source owner and factory operators.")));
827
+ }
749
828
  if (!person.api_tier && r.actor.person_id !== person.id) {
750
829
  return void res.status(403).type("html").send(page("Miadi record", recordForm(String(req.params.id), `Reading records needs Miadi's read tier; ${person.name} has none.`)));
751
830
  }
@@ -806,8 +885,12 @@ function approvePage(deps, r, o = {}) {
806
885
  const open = r.approval_state === "awaiting_approval";
807
886
  // A decision being recorded offers no buttons: a second click would decide the same request again.
808
887
  const actionable = open && !isBeingDecided(r.id);
888
+ // Freshness binds a decision still to come; a decided run shows the authorization it holds (#926 review R1).
889
+ const authorization = r.kind === "run_request" ? authorizeProvider(r, deps.ledger.all(), { requireFresh: open }) : undefined;
890
+ const blocked = Boolean(authorization && !authorization.authorized);
809
891
  return page("Approve a Miadi request", `<h1>${open ? "Approve a request" : "A decided request"}</h1>
810
- ${actionable ? `<p>${escapeHtml(consequenceOf(r))} Declining records your refusal. Either is kept in the ledger with your name.</p>` : ""}
892
+ ${actionable ? `<p>${escapeHtml(consequenceOf(r, authorization))} Declining records your refusal. Either is kept in the ledger with your name.</p>` : ""}
893
+ ${authorization ? providerSection(r, authorization, actionable ? o.chooserToken : undefined) : ""}
811
894
  ${summary(r)}
812
895
  ${projectionBlock(projectionOf(r, deps), open ? undefined : r.completion_receipt)}
813
896
  ${o.error ? `<p class="err" role="alert">${escapeHtml(o.error)}</p>` : ""}
@@ -818,8 +901,10 @@ ${actionable && o.token
818
901
  <label for="i">Instructions (needed to approve with changes or to send back)</label>
819
902
  <textarea id="i" name="instructions" rows="4"></textarea>
820
903
  <div class="row">
821
- <button class="primary" type="submit" name="decision" value="approve">Approve and run</button>
822
- <button type="submit" name="decision" value="approve_with_changes">Approve with my changes</button>
904
+ ${blocked
905
+ ? ""
906
+ : `<button class="primary" type="submit" name="decision" value="approve">Approve and run</button>
907
+ <button type="submit" name="decision" value="approve_with_changes">Approve with my changes</button>`}
823
908
  <button type="submit" name="decision" value="request_revision">Send back for revision</button>
824
909
  <button type="submit" name="decision" value="decline">Decline</button>
825
910
  </div></form>`
@@ -834,6 +919,87 @@ ${actionable && o.token
834
919
  <div class="row"><button class="primary" type="submit" name="decision" value="rerun">Run it again</button></div></form>`
835
920
  : ""}`);
836
921
  }
922
+ /**
923
+ * Which provider a run launches with, who chose it, and, when the requester's own choice is
924
+ * missing, the separate control only the requester sees (jgwill/Miadi#926).
925
+ */
926
+ function providerSection(r, a, chooserToken) {
927
+ const lines = [
928
+ `<dt>Provider</dt><dd>${escapeHtml(a.provider ?? "the factory runner's default")}</dd>`,
929
+ `<dt>Asked for</dt><dd>${escapeHtml(a.requested ?? "none named")}${a.requester.client ? ` (through ${escapeHtml(a.requester.client)})` : ""}</dd>`,
930
+ `<dt>Authorized by</dt><dd>${escapeHtml(a.state.replace(/_/g, " "))}</dd>`,
931
+ ].join("");
932
+ const choose = a.state === "needs_person_choice" && chooserToken && a.provider
933
+ ? `<form method="post">
934
+ <input type="hidden" name="person_token" value="${escapeHtml(chooserToken)}">
935
+ <input type="hidden" name="version" value="${versionOf(r)}">
936
+ <input type="hidden" name="provider" value="${escapeHtml(a.provider)}">
937
+ <label><input type="checkbox" name="confirm" value="yes" required> I choose ${escapeHtml(a.provider)} for this mission (version ${versionOf(r)}) myself. It does not cover any other mission.</label>
938
+ <div class="row"><button type="submit" name="decision" value="choose_provider">Choose ${escapeHtml(a.provider)} for this mission</button></div>
939
+ </form>`
940
+ : a.state === "needs_person_choice" && !isPersonId(r.actor.person_id)
941
+ ? ""
942
+ : a.state === "needs_person_choice"
943
+ ? `<p>Only ${escapeHtml(r.actor.name)}, who asked, can choose ${escapeHtml(a.provider ?? "")} for this mission, signed in here. Within ${CHOICE_TTL_MS / 3_600_000} hours of that choice it can be approved.</p>`
944
+ : "";
945
+ return `<section aria-label="Provider"><dl>${lines}</dl><p>${escapeHtml(a.explanation)}</p>${choose}</section>`;
946
+ }
947
+ /** The person's own provider policy page: read and change it, signed in with their person token (jgwill/Miadi#926). */
948
+ export function registerProviderPolicyRoutes(app, deps) {
949
+ const form = express.urlencoded({ extended: false, limit: "16kb" });
950
+ const signIn = (error) => page("Your provider policy", `<h1>Your provider policy</h1><p>Which agent providers a request made in your name may name. Sign in to read or change yours; it applies to your requests only.</p>
951
+ ${error ? `<p class="err" role="alert">${escapeHtml(error)}</p>` : ""}
952
+ <form method="post"><label for="t">Your Miadi person token</label><input id="t" name="person_token" type="password" autocomplete="off" required>
953
+ <div class="row"><button class="primary" type="submit">Read my policy</button></div></form>`);
954
+ app.get("/providers", (_req, res) => void res.type("html").send(signIn()));
955
+ app.post("/providers", form, async (req, res) => {
956
+ const body = (req.body ?? {});
957
+ const token = String(body.person_token ?? "").trim();
958
+ let person;
959
+ try {
960
+ person = await deps.identify(token);
961
+ }
962
+ catch (error) {
963
+ if (!(error instanceof MiadiUnavailable))
964
+ throw error;
965
+ return void res.status(503).type("html").send(signIn("Miadi could not be reached. Try again in a minute."));
966
+ }
967
+ if (!person)
968
+ return void res.status(401).type("html").send(signIn("Miadi did not recognise that person token."));
969
+ if (!isPersonId(person.id))
970
+ return void res.status(403).type("html").send(signIn("A service token has no provider policy; a person sets their own."));
971
+ let saved = "";
972
+ if (body.codex !== undefined) {
973
+ if (!CODEX_POLICIES.includes(body.codex))
974
+ return void res.status(400).type("html").send(signIn("Unknown Codex policy."));
975
+ if (providerPolicyOf(deps.ledger.all(), person.id).codex !== body.codex) {
976
+ deps.ledger.append({
977
+ id: newRecordId("provider_policy"),
978
+ kind: "provider_policy",
979
+ created_at: new Date().toISOString(),
980
+ actor: { person_id: person.id, name: person.name, client_id: "providers-page" },
981
+ refs: [],
982
+ evidence_refs: [],
983
+ claim_category: "observed",
984
+ body: { codex: body.codex },
985
+ });
986
+ logDecision(deps, { phase: "provider_policy_set", person: person.id, codex: body.codex });
987
+ }
988
+ saved = "<p>Saved. It applies to your requests from now on, including a failed run of yours that is run again. A per-mission choice you already made still covers its mission.</p>";
989
+ }
990
+ const policy = providerPolicyOf(deps.ledger.all(), person.id);
991
+ const option = (value, label) => `<label><input type="radio" name="codex" value="${value}"${policy.codex === value ? " checked" : ""}> ${escapeHtml(label)}</label>`;
992
+ res.type("html").send(page("Your provider policy", `<h1>${escapeHtml(person.name)}'s provider policy</h1>${saved}
993
+ <dl><dt>Codex</dt><dd>${escapeHtml(policy.codex.replace(/_/g, " "))}${policy.source === "default" ? " (the default)" : `, set ${escapeHtml(policy.set_at ?? "")}`}</dd>
994
+ <dt>Other providers</dt><dd>Pi, Copilot and Claude Code run when a request names them and they are installed; Pi and Copilot may also be picked by the host's order.</dd></dl>
995
+ <form method="post"><input type="hidden" name="person_token" value="${escapeHtml(token)}">
996
+ <fieldset><legend>When a request in my name names Codex</legend>
997
+ ${option("per_mission_choice", "Codex runs only when I choose it for that mission on its approval page (default).")}
998
+ ${option("selectable", "Codex may be named for my requests without a per-mission choice.")}
999
+ </fieldset>
1000
+ <div class="row"><button class="primary" type="submit">Save my policy</button></div></form>`));
1001
+ });
1002
+ }
837
1003
  /** How a request is named to a person. */
838
1004
  export const KIND_LABELS = {
839
1005
  run_request: "Agent run",
@@ -108,7 +108,9 @@ function nextAction(record, standing, queue, result) {
108
108
  case "dispatching":
109
109
  return queue?.state === "delivered"
110
110
  ? "The brief is with its session. Follow it, or steer it with request_session_steer inside the approved grant."
111
- : "Wait for the factory intake to deliver the brief.";
111
+ : queue?.state === "refused"
112
+ ? "The factory runner refused to launch its provider; nothing runs. The requester decides on a new request with a provider they chose."
113
+ : "Wait for the factory intake to deliver the brief.";
112
114
  case "failed":
113
115
  return standing.rerun.available ? "A write-tier person may run it again with the same approval." : "Read the failure and decide.";
114
116
  case "completed":
@@ -1,4 +1,5 @@
1
1
  import { type JevConfig, type ProviderAvailability, type ProviderId } from "./providers.js";
2
+ import { type ProviderAuthorization } from "./provider-policy.js";
2
3
  export declare const PREFLIGHT_CONTRACT = "miadi.mission_model_preflight";
3
4
  export declare const PREFLIGHT_VERSION = 1;
4
5
  /** Below this, a Jev choice is not acted on (the ≥0.8 practice of jgwill/Miadi#770). */
@@ -34,7 +35,7 @@ export interface LaunchEvidence {
34
35
  /** What shows it (a process line, a pane header); reported by the caller, not checked by the connector. */
35
36
  evidence: string;
36
37
  }
37
- export type PreflightState = "recommended" | "abstained" | "provider_mismatch";
38
+ export type PreflightState = "recommended" | "abstained" | "provider_mismatch" | "provider_not_authorized";
38
39
  export type DeciderSource = "jev" | "static_fallback" | "synthetic_fixture";
39
40
  export type RouterState = "answered" | "not_requested" | "router_unconfigured" | "router_unavailable" | "invalid_answer";
40
41
  export interface ModelPreflight {
@@ -57,6 +58,8 @@ export interface ModelPreflight {
57
58
  /** approved: the request was approved with this provider. requested: it is not approved (yet); the provider is only asked for. */
58
59
  provider_basis: "approved" | "requested";
59
60
  candidates: ModelCandidate[];
61
+ /** Whether the mission's provider is authorized and by whom (jgwill/Miadi#926). */
62
+ provider_authorization?: Record<string, unknown>;
60
63
  decider: {
61
64
  source: DeciderSource;
62
65
  router: RouterState;
@@ -117,7 +120,7 @@ export interface JevRequest {
117
120
  }
118
121
  /** The model a provider's own config names, when it names one. Only that key is read. */
119
122
  export declare function configuredModel(provider: ProviderId, home?: string): string | null;
120
- export declare function candidatesFor(found: ProviderAvailability[], approved: ProviderId | null, modelOf?: (p: ProviderId) => string | null, basis?: "approved" | "requested"): ModelCandidate[];
123
+ export declare function candidatesFor(found: ProviderAvailability[], approved: ProviderId | null, modelOf?: (p: ProviderId) => string | null, basis?: "approved" | "requested", authorization?: Pick<ProviderAuthorization, "authorized" | "provider" | "requester">): ModelCandidate[];
121
124
  /** The least review a task gets, whatever any decider says. Jev may raise it, never lower it. */
122
125
  export declare function reviewFloor(task: TaskType, criticality: Criticality): ReviewTier;
123
126
  export declare function decisionState(task: TaskType, criticality: Criticality, candidates: ModelCandidate[]): Record<string, unknown>;
@@ -134,6 +137,10 @@ export interface PreflightInput {
134
137
  task: TaskType;
135
138
  criticality: Criticality;
136
139
  candidates: ModelCandidate[];
140
+ /** Whether the mission's provider is authorized, and by whom (jgwill/Miadi#926); shown as given. */
141
+ provider_authorization?: Record<string, unknown> & {
142
+ authorized?: boolean;
143
+ };
137
144
  launch?: LaunchEvidence;
138
145
  /** The decider to consult, when the caller asked for one; absent means static fallback only. */
139
146
  adapter?: JevAdapter;
@@ -7,6 +7,9 @@
7
7
  * is kept (no switching without a person), Claude Code only when named, an
8
8
  * uninstalled or sign-in-less provider is ineligible, and a reported launch on
9
9
  * another provider fails closed as provider_mismatch before anything is asked.
10
+ * - Codex is eligible only with its requester's own choice for the mission or
11
+ * their own policy (provider-policy.ts, jgwill/Miadi#926); a reported Codex
12
+ * launch without either fails closed as provider_not_authorized.
10
13
  * - Jev is consulted only when the caller asks (consult_jev) and the connector's
11
14
  * environment holds a Jev key. The state sent holds the task type, the
12
15
  * criticality and the candidates: no task text, mission id, repository name or
@@ -25,6 +28,7 @@ import { readFileSync } from "node:fs";
25
28
  import { homedir } from "node:os";
26
29
  import { join } from "node:path";
27
30
  import { PROVIDERS } from "./providers.js";
31
+ import { needsPersonChoice } from "./provider-policy.js";
28
32
  export const PREFLIGHT_CONTRACT = "miadi.mission_model_preflight";
29
33
  export const PREFLIGHT_VERSION = 1;
30
34
  /** Below this, a Jev choice is not acted on (the ≥0.8 practice of jgwill/Miadi#770). */
@@ -52,7 +56,7 @@ export function configuredModel(provider, home = homedir()) {
52
56
  }
53
57
  return null;
54
58
  }
55
- export function candidatesFor(found, approved, modelOf = (p) => configuredModel(p), basis = "approved") {
59
+ export function candidatesFor(found, approved, modelOf = (p) => configuredModel(p), basis = "approved", authorization) {
56
60
  return PROVIDERS.map((spec) => {
57
61
  const a = found.find((p) => p.id === spec.id);
58
62
  const installed = Boolean(a?.installed);
@@ -68,6 +72,9 @@ export function candidatesFor(found, approved, modelOf = (p) => configuredModel(
68
72
  [eligible, reason] = [false, `Not the ${basis} provider (${approved}); switching needs a person's approval.`];
69
73
  else if (!approved && !spec.selectable_by_default)
70
74
  [eligible, reason] = [false, `${spec.name} runs only when a request names it.`];
75
+ else if (needsPersonChoice(spec.id) && !(authorization?.authorized && authorization.provider === spec.id)) {
76
+ [eligible, reason] = [false, `${spec.name} runs only when ${authorization?.requester.name ?? "the requester"} chooses it for this mission, or their own policy allows it (jgwill/Miadi#926).`];
77
+ }
71
78
  return {
72
79
  option: model ? `${spec.id}:${model}` : spec.id,
73
80
  provider: spec.id,
@@ -212,6 +219,7 @@ export async function modelPreflight(input) {
212
219
  approved_provider,
213
220
  provider_basis: input.provider_basis ?? "approved",
214
221
  candidates,
222
+ ...(input.provider_authorization ? { provider_authorization: input.provider_authorization } : {}),
215
223
  evidence: { model_cost: "unknown", quota: "unknown" },
216
224
  policy: {
217
225
  deterministic: true,
@@ -223,6 +231,20 @@ export async function modelPreflight(input) {
223
231
  ? { approved_provider, actual: { ...input.launch, source: "reported" }, mismatch: approved_provider ? input.launch.provider !== approved_provider : null }
224
232
  : { approved_provider, actual: "unverified", mismatch: null };
225
233
  // Fail closed before anything is asked or spent.
234
+ if (input.launch && needsPersonChoice(input.launch.provider) && !(input.provider_authorization?.authorized && approved_provider === input.launch.provider)) {
235
+ return {
236
+ ...base,
237
+ state: "provider_not_authorized",
238
+ decider: { source: "static_fallback", router: "not_requested", calibration: "not_applicable", derivation: "deterministic policy: Codex launched without its requester's own choice or policy (jgwill/Miadi#926)" },
239
+ recommendation: {
240
+ confidence: null,
241
+ review_tier: floor,
242
+ explanation: `Reported running on ${input.launch.provider}, which no choice of the requester's authorizes for this mission. Nothing proceeds on it; the requester chooses it on the approval page, or the mission runs on another provider.`,
243
+ abstain_reasons: ["provider_not_authorized"],
244
+ },
245
+ launch,
246
+ };
247
+ }
226
248
  if (launch.mismatch) {
227
249
  return {
228
250
  ...base,
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Codex runs only on its requester's own choice for that one mission (jgwill/Miadi#926).
3
+ *
4
+ * The assistant that asks (ChatGPT or any MCP client) may name provider "codex", but that
5
+ * name is the caller's, not the person's. The connector cannot read the chat, so it never
6
+ * treats the provider field as the person's choice. Codex is authorized for a run only by:
7
+ *
8
+ * - a `provider_choice` record the requester made themself on the request's approval page,
9
+ * a browser form signed in with their own Miadi person token, outside the MCP channel
10
+ * (an OAuth client never holds the person token). The choice is bound to the request id,
11
+ * its version and its content hash, and must be fresh (CHOICE_TTL_MS) when the request
12
+ * is approved. A revision makes it stale. It covers that mission only;
13
+ * - or the requester's own provider policy, set by them on the connector's /providers page,
14
+ * saying Codex may be named for their requests without a per-mission choice.
15
+ *
16
+ * A generic approval (page or card), the caller's provider field, the host's preference
17
+ * order, a Jev recommendation, and a choice made by anyone other than the requester
18
+ * authorize nothing. Other providers keep their regular policy (jgwill/Miadi#893, #913).
19
+ * The check runs when a request is made or revised, when it is approved, when its brief
20
+ * is written (approval and every rerun), and the factory runner checks the brief again
21
+ * before it launches anything.
22
+ */
23
+ import type { ConnectorRecord } from "./records.js";
24
+ /** Providers a request may name only with the requester's own per-mission choice. */
25
+ export declare const CHOICE_PROVIDERS: readonly string[];
26
+ export declare const CODEX_POLICIES: readonly ["per_mission_choice", "selectable"];
27
+ export type CodexPolicy = (typeof CODEX_POLICIES)[number];
28
+ export declare const DEFAULT_CODEX_POLICY: CodexPolicy;
29
+ /** How long a per-mission choice stays good for the approval that follows it. */
30
+ export declare const CHOICE_TTL_MS: number;
31
+ export declare function needsPersonChoice(provider: string | null | undefined): boolean;
32
+ /** Whether a person id may make a choice or set a policy: a person, never a service token. */
33
+ export declare function isPersonId(personId: string | undefined): boolean;
34
+ export interface ProviderPolicy {
35
+ person_id: string;
36
+ codex: CodexPolicy;
37
+ source: "default" | "person";
38
+ record?: string;
39
+ set_at?: string;
40
+ }
41
+ /** A person's provider policy: the latest one they set themself, else the default. */
42
+ export declare function providerPolicyOf(records: ConnectorRecord[], personId: string): ProviderPolicy;
43
+ export interface ProviderChoiceView {
44
+ record: string;
45
+ provider: string;
46
+ by: {
47
+ person_id: string;
48
+ name: string;
49
+ };
50
+ at: string;
51
+ version: number;
52
+ content_hash: string;
53
+ via?: string;
54
+ }
55
+ export type AuthorizationState = "not_required" | "person_choice" | "policy_selectable" | "needs_person_choice";
56
+ export interface ProviderAuthorization {
57
+ state: AuthorizationState;
58
+ authorized: boolean;
59
+ /** The provider the run would launch with (the resolved one), or null when the runner's default applies. */
60
+ provider: string | null;
61
+ /** The provider field as the caller wrote it: evidence of what was asked, never of who chose it. */
62
+ requested: string | null;
63
+ requester: {
64
+ person_id: string;
65
+ name: string;
66
+ client?: string;
67
+ };
68
+ policy: {
69
+ codex: CodexPolicy;
70
+ source: ProviderPolicy["source"];
71
+ record?: string;
72
+ set_at?: string;
73
+ };
74
+ choice?: ProviderChoiceView;
75
+ /** Choices recorded for this request that do not bind it (another version, another person, too old). */
76
+ not_binding?: Array<{
77
+ record: string;
78
+ why: string;
79
+ }>;
80
+ explanation: string;
81
+ }
82
+ /**
83
+ * Whether the provider a request names may launch, and why. Pure: the ledger is read,
84
+ * nothing is written. `requireFresh` (the approval) also refuses a choice older than
85
+ * CHOICE_TTL_MS; the brief written for an approved request checks the binding only.
86
+ */
87
+ export declare function authorizeProvider(record: ConnectorRecord, records: ConnectorRecord[], opts?: {
88
+ now?: number;
89
+ requireFresh?: boolean;
90
+ }): ProviderAuthorization;
91
+ /** The authorization as the brief carries it to the factory runner: attributes it checks before launching. */
92
+ export declare function briefAttributes(a: ProviderAuthorization): Record<string, string>;
93
+ /** The authorization as a receipt keeps it: no body text, only who, what and which record. */
94
+ export declare function authorizationReceipt(a: ProviderAuthorization): Record<string, unknown>;