@miadi/connector 0.1.18 → 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.
@@ -1,10 +1,12 @@
1
1
  export declare const DECISIONS_CARD_URI = "ui://miadi/decisions-v1.html";
2
+ /** The default lifetime of a card's decision token, in seconds; MIADI_CONNECTOR_DECISION_TOKEN_TTL changes it. */
2
3
  export declare const DECISION_TOKEN_TTL: number;
3
4
  /** Where the tokens travel in a result's `_meta`, keyed by record id. */
4
5
  export declare const DECISION_TOKENS_META = "miadi/decisionTokens";
5
6
  export declare class DecisionTokens {
7
+ readonly ttl: number;
6
8
  private readonly secret;
7
- constructor(stateDir: string);
9
+ constructor(stateDir: string, ttl?: number);
8
10
  private sign;
9
11
  /** A token for one card: one request, at the version the card shows, for one person in one client. */
10
12
  issue(recordId: string, personId: string, clientId: string, now?: number, version?: number): string;
@@ -17,4 +19,4 @@ export declare class DecisionTokens {
17
19
  * and calls `decide_request` through the host. In a host without that bridge it
18
20
  * shows each request's approval page instead.
19
21
  */
20
- export declare const DECISIONS_CARD_HTML = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<style>\n:root{color-scheme:light dark;--fg:#1d1b18;--muted:#5b5650;--line:#d9d4cc;--accent:#7a4a00;--ok:#1d6b3a;--err:#b00020;--chip:#f1ede6}\n@media (prefers-color-scheme:dark){:root{--fg:#ece8e1;--muted:#b3ada4;--line:#3a3631;--accent:#ffd700;--ok:#7fd49b;--err:#ff8a80;--chip:#24211d}}\n*{box-sizing:border-box}\nbody{margin:0;color:var(--fg);font:15px/1.45 system-ui,sans-serif;background:transparent}\nmain{padding:12px;display:grid;gap:12px}\n.empty,.note{color:var(--muted)}\narticle{border:1px solid var(--line);border-radius:10px;padding:12px}\nh2{font-size:15px;margin:0 0 4px}\n.kind{display:inline-block;font-size:12px;padding:1px 8px;border-radius:999px;background:var(--chip);color:var(--muted);margin-bottom:6px}\n.meta{font-size:13px;color:var(--muted);margin:0 0 6px}\n.does{margin:6px 0;font-weight:600}\ndetails{margin:6px 0}\nsummary{cursor:pointer;color:var(--muted);font-size:13px}\npre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;margin:6px 0 0;max-height:240px;overflow:auto}\ntextarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--fg);font:inherit;margin-top:8px}\n.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}\nbutton{padding:8px 12px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;cursor:pointer;min-height:40px}\nbutton.primary{border-color:var(--accent);outline:1px solid var(--accent)}\nbutton:focus-visible,a:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}\nbutton[disabled]{opacity:.55;cursor:default}\na{color:inherit}\n.done{color:var(--ok);font-weight:600}\n.fail{color:var(--err);font-weight:600}\n</style></head><body><main id=\"root\" aria-live=\"polite\"><p class=\"note\">Loading requests\u2026</p></main>\n<script>\n(function(){\n var root=document.getElementById(\"root\");\n var decided={};\n // A redraw while a decision is being recorded must not offer the buttons again.\n var pending={};\n var errors={};\n function el(tag,attrs,text){var e=document.createElement(tag);if(attrs)for(var k in attrs){if(k===\"class\")e.className=attrs[k];else e.setAttribute(k,attrs[k]);}if(text!=null)e.textContent=text;return e;}\n function host(){return window.openai||null;}\n function data(){var h=host();return h&&h.toolOutput?h.toolOutput:null;}\n function tokens(){var h=host();var m=h&&h.toolResponseMetadata;return (m&&m[\"miadi/decisionTokens\"])||{};}\n function render(){\n var d=data();root.textContent=\"\";\n if(!d){root.appendChild(el(\"p\",{class:\"note\"},\"Waiting for the request list.\"));return;}\n var list=d.decisions||[];\n if(!list.length){root.appendChild(el(\"p\",{class:\"empty\"},d.message||\"No request is waiting for a decision.\"));return;}\n if(!d.can_decide_here&&d.reason){root.appendChild(el(\"p\",{class:\"note\"},d.reason));}\n var t=tokens();\n list.forEach(function(r){root.appendChild(card(r,t[r.id]));});\n }\n // Where a request stands, as the connector projected it: the label, its lines, and the review it produced.\n function decidedBlock(d){\n var box=el(\"div\",{role:\"status\",class:d.cls});\n if(d.note)box.appendChild(el(\"p\",{class:\"line\"},d.note));\n box.appendChild(el(\"p\",{class:\"label\"},d.label));\n (d.lines||[]).forEach(function(l){box.appendChild(el(\"p\",{class:\"line\"},l));});\n if(d.url){var p=el(\"p\",{class:\"line\"});p.appendChild(el(\"a\",{href:d.url,target:\"_blank\",rel:\"noopener\"},d.url));box.appendChild(p);}\n return box;\n }\n function toolResult(res){\n var out=(res&&(res.structuredContent||res.structured_content))||null;\n if(!out&&res&&res.content&&res.content[0]&&res.content[0].text){try{out=JSON.parse(res.content[0].text);}catch(e){out={message:res.content[0].text};}}\n return out;\n }\n function projectionView(p,note){return {cls:p.stage===\"failed\"?\"fail\":\"done\",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}\n function card(r,token){\n var a=el(\"article\",{\"aria-label\":r.title});\n a.appendChild(el(\"span\",{class:\"kind\"},r.kind_label||r.kind));\n a.appendChild(el(\"h2\",null,r.title));\n a.appendChild(el(\"p\",{class:\"meta\"},\"Asked by \"+r.by+(r.via?\" through \"+r.via:\"\")+\" \u00B7 \"+new Date(r.created_at).toLocaleString()+(r.circle?\" \u00B7 \"+r.circle+\" circle\":\"\")));\n a.appendChild(el(\"p\",{class:\"does\"},r.consequence));\n if(r.intention)a.appendChild(el(\"p\",null,r.intention));\n var det=el(\"details\");det.appendChild(el(\"summary\",null,\"What exactly is asked\"));det.appendChild(el(\"pre\",null,r.detail||\"\"));a.appendChild(det);\n var status=el(\"p\",{role:\"status\"});\n if(r.projection&&r.projection.stage!==\"awaiting_decision\"&&(!decided[r.id]||[\"recording\",\"dispatching\"].indexOf(r.projection.stage)===-1)){decided[r.id]=projectionView(r.projection);}\n if(decided[r.id]){\n var shown=decided[r.id];a.appendChild(decidedBlock(shown));\n if([\"recording\",\"dispatching\"].indexOf(shown.stage)!==-1&&host()&&host().callTool){\n var refresh=el(\"button\",{type:\"button\"},\"Refresh status\");\n refresh.addEventListener(\"click\",function(){\n refresh.disabled=true;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(p){decided[r.id]=projectionView(p);delete errors[r.id];}\n else{errors[r.id]=(out&&out.message)||\"Status could not be refreshed.\";}\n render();\n },function(err){errors[r.id]=\"Status could not be refreshed: \"+String(err&&err.message||err);render();});\n });\n a.appendChild(refresh);\n }\n if(errors[r.id])a.appendChild(el(\"p\",{class:\"fail\",role:\"status\"},errors[r.id]));\n return a;\n }\n if(pending[r.id]){status.className=\"note\";status.textContent=\"Recording your decision\u2026\";a.appendChild(status);return a;}\n if(!token||!host()||!host().callTool){\n var p=el(\"p\",{class:\"note\"});p.appendChild(document.createTextNode(\"Decide on its approval page: \"));\n var link=el(\"a\",{href:r.approval_url,target:\"_blank\",rel:\"noopener\"},\"open the approval page\");p.appendChild(link);a.appendChild(p);return a;\n }\n var ta=el(\"textarea\",{\"aria-label\":\"Instructions or changes (needed to approve with changes or send back)\",placeholder:\"Instructions or changes (needed to approve with changes or send back)\"});\n a.appendChild(ta);\n var row=el(\"div\",{class:\"row\"});\n var buttons=[[\"approve\",\"Approve\",\"primary\"],[\"approve_with_changes\",\"Approve with my changes\",\"\"],[\"request_revision\",\"Send back for revision\",\"\"],[\"decline\",\"Decline\",\"\"]];\n var btns=[];\n buttons.forEach(function(b){var x=el(\"button\",{type:\"button\",class:b[2]},b[1]);x.addEventListener(\"click\",function(){go(b[0]);});btns.push(x);row.appendChild(x);});\n a.appendChild(row);a.appendChild(status);\n if(errors[r.id]){status.className=\"fail\";status.textContent=errors[r.id];}\n function go(choice){\n var text=ta.value.trim();\n if((choice===\"approve_with_changes\"||choice===\"request_revision\")&&!text){status.className=\"fail\";status.textContent=choice===\"request_revision\"?\"Say what to revise.\":\"Write the changes to apply.\";ta.focus();return;}\n btns.forEach(function(x){x.disabled=true;});status.className=\"note\";status.textContent=\"Recording your decision\u2026\";pending[r.id]=true;delete errors[r.id];\n host().callTool(\"decide_request\",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){\n var out=toolResult(res);\n // The connector answers with the request's projection, also when a click finds it decided already.\n var proj=out&&out.projection;\n var already=!!(out&&out.error===\"already_decided\");\n var ok=out&&!out.error;\n delete pending[r.id];\n if(proj){decided[r.id]=projectionView(proj,already?\"This request was already decided. Here is where it stands.\":null);}\n else{decided[r.id]={cls:ok?\"done\":\"fail\",label:ok?(out.summary||\"Recorded.\"):((out&&out.message)||\"Not recorded.\"),lines:[],url:null,note:null};}\n render();\n },function(err){delete pending[r.id];errors[r.id]=\"Not recorded: \"+(err&&err.message?err.message:String(err));render();});\n }\n return a;\n }\n window.addEventListener(\"openai:set_globals\",render);\n render();\n})();\n</script></body></html>";
22
+ export declare const DECISIONS_CARD_HTML = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<style>\n:root{color-scheme:light dark;--fg:#1d1b18;--muted:#5b5650;--line:#d9d4cc;--accent:#7a4a00;--ok:#1d6b3a;--err:#b00020;--chip:#f1ede6}\n@media (prefers-color-scheme:dark){:root{--fg:#ece8e1;--muted:#b3ada4;--line:#3a3631;--accent:#ffd700;--ok:#7fd49b;--err:#ff8a80;--chip:#24211d}}\n*{box-sizing:border-box}\nbody{margin:0;color:var(--fg);font:15px/1.45 system-ui,sans-serif;background:transparent}\nmain{padding:12px;display:grid;gap:12px}\n.empty,.note{color:var(--muted)}\narticle{border:1px solid var(--line);border-radius:10px;padding:12px}\nh2{font-size:15px;margin:0 0 4px}\n.kind{display:inline-block;font-size:12px;padding:1px 8px;border-radius:999px;background:var(--chip);color:var(--muted);margin-bottom:6px}\n.meta{font-size:13px;color:var(--muted);margin:0 0 6px}\n.does{margin:6px 0;font-weight:600}\ndetails{margin:6px 0}\nsummary{cursor:pointer;color:var(--muted);font-size:13px}\npre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;margin:6px 0 0;max-height:240px;overflow:auto}\ntextarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--fg);font:inherit;margin-top:8px}\n.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}\nbutton{padding:8px 12px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;cursor:pointer;min-height:40px}\nbutton.primary{border-color:var(--accent);outline:1px solid var(--accent)}\nbutton:focus-visible,a:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}\nbutton[disabled]{opacity:.55;cursor:default}\na{color:inherit}\n.done{color:var(--ok);font-weight:600}\n.fail{color:var(--err);font-weight:600}\n</style></head><body><main id=\"root\" aria-live=\"polite\"><p class=\"note\">Loading requests\u2026</p></main>\n<script>\n(function(){\n var root=document.getElementById(\"root\");\n var decided={};\n // A redraw while a decision is being recorded must not offer the buttons again.\n var pending={};\n var errors={};\n // A decision still running is followed to its outcome (jgwill/Miadi#924): one read every POLL_MS, at most POLL_MAX.\n var POLL_MS=3000,POLL_MAX=100,polls={};\n // An error that leaves the request open offers its approval page under it.\n var relink={};\n function el(tag,attrs,text){var e=document.createElement(tag);if(attrs)for(var k in attrs){if(k===\"class\")e.className=attrs[k];else e.setAttribute(k,attrs[k]);}if(text!=null)e.textContent=text;return e;}\n function host(){return window.openai||null;}\n function data(){var h=host();return h&&h.toolOutput?h.toolOutput:null;}\n function tokens(){var h=host();var m=h&&h.toolResponseMetadata;return (m&&m[\"miadi/decisionTokens\"])||{};}\n function render(){\n var d=data();root.textContent=\"\";\n if(!d){root.appendChild(el(\"p\",{class:\"note\"},\"Waiting for the request list.\"));return;}\n var list=d.decisions||[];\n if(!list.length){root.appendChild(el(\"p\",{class:\"empty\"},d.message||\"No request is waiting for a decision.\"));return;}\n if(!d.can_decide_here&&d.reason){root.appendChild(el(\"p\",{class:\"note\"},d.reason));}\n var t=tokens();\n list.forEach(function(r){root.appendChild(card(r,t[r.id]));});\n }\n // Where a request stands, as the connector projected it: the label, its lines, and the review it produced.\n function decidedBlock(d){\n var box=el(\"div\",{role:\"status\",class:d.cls});\n if(d.note)box.appendChild(el(\"p\",{class:\"line\"},d.note));\n box.appendChild(el(\"p\",{class:\"label\"},d.label));\n (d.lines||[]).forEach(function(l){box.appendChild(el(\"p\",{class:\"line\"},l));});\n if(d.url){var p=el(\"p\",{class:\"line\"});p.appendChild(el(\"a\",{href:d.url,target:\"_blank\",rel:\"noopener\"},d.url));box.appendChild(p);}\n return box;\n }\n function toolResult(res){\n var out=(res&&(res.structuredContent||res.structured_content))||null;\n if(!out&&res&&res.content&&res.content[0]&&res.content[0].text){try{out=JSON.parse(res.content[0].text);}catch(e){out={message:res.content[0].text};}}\n return out;\n }\n function projectionView(p,note){return {cls:p.stage===\"failed\"?\"fail\":[\"recording\",\"dispatching\"].indexOf(p.stage)!==-1?\"note\":\"done\",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}\n // Read where a request stands. Still running: read again later. Never decided (the call was lost on the way): offer the decision again.\n function follow(r,lost){\n if(!host()||!host().callTool)return;\n polls[r.id]=(polls[r.id]||0)+1;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(!p){if(lost){delete decided[r.id];errors[r.id]=lost;}render();return;}\n if(p.stage===\"awaiting_decision\"){delete decided[r.id];if(lost)errors[r.id]=lost+\" Nothing was decided; decide again.\";render();return;}\n var note=decided[r.id]&&decided[r.id].note;\n decided[r.id]=projectionView(p,note);delete errors[r.id];render();\n if(p.stage===\"recording\"&&polls[r.id]<POLL_MAX)setTimeout(function(){follow(r);},POLL_MS);\n },function(err){\n if(lost){decided[r.id]={cls:\"note\",stage:\"recording\",label:\"Your decision may be recorded. Checking again\u2026\",lines:[],url:null,note:null};render();}\n if(polls[r.id]<POLL_MAX)setTimeout(function(){follow(r,lost);},POLL_MS);\n });\n }\n function card(r,token){\n var a=el(\"article\",{\"aria-label\":r.title});\n a.appendChild(el(\"span\",{class:\"kind\"},r.kind_label||r.kind));\n a.appendChild(el(\"h2\",null,r.title));\n a.appendChild(el(\"p\",{class:\"meta\"},\"Asked by \"+r.by+(r.via?\" through \"+r.via:\"\")+\" \u00B7 \"+new Date(r.created_at).toLocaleString()+(r.circle?\" \u00B7 \"+r.circle+\" circle\":\"\")));\n a.appendChild(el(\"p\",{class:\"does\"},r.consequence));\n if(r.intention)a.appendChild(el(\"p\",null,r.intention));\n var det=el(\"details\");det.appendChild(el(\"summary\",null,\"What exactly is asked\"));det.appendChild(el(\"pre\",null,r.detail||\"\"));a.appendChild(det);\n var status=el(\"p\",{role:\"status\"});\n if(r.projection&&r.projection.stage!==\"awaiting_decision\"&&(!decided[r.id]||[\"recording\",\"dispatching\"].indexOf(r.projection.stage)===-1)){decided[r.id]=projectionView(r.projection);}\n if(decided[r.id]){\n var shown=decided[r.id];a.appendChild(decidedBlock(shown));\n if(shown.stage===\"recording\"&&polls[r.id]===undefined&&host()&&host().callTool){polls[r.id]=0;setTimeout(function(){follow(r);},POLL_MS);}\n if([\"recording\",\"dispatching\"].indexOf(shown.stage)!==-1&&host()&&host().callTool){\n var refresh=el(\"button\",{type:\"button\"},\"Refresh status\");\n refresh.addEventListener(\"click\",function(){\n refresh.disabled=true;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(p){decided[r.id]=projectionView(p);delete errors[r.id];}\n else{errors[r.id]=(out&&out.message)||\"Status could not be refreshed.\";}\n render();\n },function(err){errors[r.id]=\"Status could not be refreshed: \"+String(err&&err.message||err);render();});\n });\n a.appendChild(refresh);\n }\n if(errors[r.id])a.appendChild(el(\"p\",{class:\"fail\",role:\"status\"},errors[r.id]));\n return a;\n }\n function approvalLink(){var p=el(\"p\",{class:\"note\"});p.appendChild(document.createTextNode(\"Decide on its approval page: \"));p.appendChild(el(\"a\",{href:r.approval_url,target:\"_blank\",rel:\"noopener\"},\"open the approval page\"));return p;}\n if(pending[r.id]){status.className=\"note\";status.textContent=\"Recording your decision\u2026\";a.appendChild(status);return a;}\n if(!token||!host()||!host().callTool){a.appendChild(approvalLink());return a;}\n var ta=el(\"textarea\",{\"aria-label\":\"Instructions or changes (needed to approve with changes or send back)\",placeholder:\"Instructions or changes (needed to approve with changes or send back)\"});\n a.appendChild(ta);\n var row=el(\"div\",{class:\"row\"});\n var buttons=[[\"approve\",\"Approve\",\"primary\"],[\"approve_with_changes\",\"Approve with my changes\",\"\"],[\"request_revision\",\"Send back for revision\",\"\"],[\"decline\",\"Decline\",\"\"]];\n var btns=[];\n buttons.forEach(function(b){var x=el(\"button\",{type:\"button\",class:b[2]},b[1]);x.addEventListener(\"click\",function(){go(b[0]);});btns.push(x);row.appendChild(x);});\n a.appendChild(row);a.appendChild(status);\n if(errors[r.id]){status.className=\"fail\";status.textContent=errors[r.id];if(relink[r.id])a.appendChild(approvalLink());}\n function go(choice){\n var text=ta.value.trim();\n if((choice===\"approve_with_changes\"||choice===\"request_revision\")&&!text){status.className=\"fail\";status.textContent=choice===\"request_revision\"?\"Say what to revise.\":\"Write the changes to apply.\";ta.focus();return;}\n btns.forEach(function(x){x.disabled=true;});status.className=\"note\";status.textContent=\"Recording your decision\u2026\";pending[r.id]=true;delete errors[r.id];\n host().callTool(\"decide_request\",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){\n var out=toolResult(res);\n // The connector answers with the request's projection, also when a click finds it decided already.\n var proj=out&&(out.projection||(out.detail&&out.detail.projection));\n var code=out&&out.error;\n delete pending[r.id];\n // The token expired or the request was revised, and it still awaits a decision: say so and keep it open.\n if(code&&(!proj||proj.stage===\"awaiting_decision\")){errors[r.id]=(out&&out.message)||\"Not recorded.\";relink[r.id]=true;render();return;}\n var note=out&&out.replayed?\"This decision was already recorded. Nothing ran again.\":code===\"decision_conflict\"?\"A different decision was recorded first. Here is where it stands.\":code===\"already_decided\"?\"This request was already decided. Here is where it stands.\":null;\n if(proj){decided[r.id]=projectionView(proj,note);}\n else{decided[r.id]={cls:code?\"fail\":\"done\",label:code?((out&&out.message)||\"Not recorded.\"):((out&&out.summary)||\"Recorded.\"),lines:[],url:null,note:null};}\n render();\n },function(err){\n // The host gave up waiting; the connector may still have recorded it. Read before offering the buttons again (#924).\n delete pending[r.id];\n decided[r.id]={cls:\"note\",stage:\"recording\",label:\"Checking whether your decision was recorded\u2026\",lines:[],url:null,note:null};\n polls[r.id]=0;render();\n follow(r,\"Not recorded: \"+(err&&err.message?err.message:String(err))+\".\");\n });\n }\n return a;\n }\n window.addEventListener(\"openai:set_globals\",render);\n render();\n})();\n</script></body></html>";
package/dist/decisions.js CHANGED
@@ -7,9 +7,14 @@
7
7
  * so the host does not offer it to the model;
8
8
  * - it requires a decision token that travels only in the `_meta` of the
9
9
  * `show_decisions` result, which hosts deliver to the card and keep from the
10
- * model. The token is bound to one request, one person and one OAuth client,
11
- * and expires. A request decided once is no longer awaiting approval, so a
12
- * token cannot be used twice.
10
+ * model. The token is bound to one request, its version, one person and one
11
+ * OAuth client, and expires (7 days by default). A request decided once is no
12
+ * longer awaiting approval, so a token cannot decide twice.
13
+ *
14
+ * The token's lifetime is not what keeps a request safe: using it needs the same
15
+ * person's bearer for the same client with `miadi:approve`, and that bearer can
16
+ * already ask show_decisions for a fresh token. A short lifetime only made cards
17
+ * left open for a few hours refuse their person (jgwill/Miadi#924).
13
18
  *
14
19
  * The sign-in must also carry `miadi:approve`, which a person grants by ticking
15
20
  * it, and Miadi must give them the write tier.
@@ -18,13 +23,17 @@ import { createHmac, timingSafeEqual } from "node:crypto";
18
23
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
19
24
  import { join } from "node:path";
20
25
  import { newSecret } from "./store.js";
26
+ import { DEFAULT_DECISION_TOKEN_TTL } from "./config.js";
21
27
  export const DECISIONS_CARD_URI = "ui://miadi/decisions-v1.html";
22
- export const DECISION_TOKEN_TTL = 30 * 60;
28
+ /** The default lifetime of a card's decision token, in seconds; MIADI_CONNECTOR_DECISION_TOKEN_TTL changes it. */
29
+ export const DECISION_TOKEN_TTL = DEFAULT_DECISION_TOKEN_TTL;
23
30
  /** Where the tokens travel in a result's `_meta`, keyed by record id. */
24
31
  export const DECISION_TOKENS_META = "miadi/decisionTokens";
25
32
  export class DecisionTokens {
33
+ ttl;
26
34
  secret;
27
- constructor(stateDir) {
35
+ constructor(stateDir, ttl = DECISION_TOKEN_TTL) {
36
+ this.ttl = ttl;
28
37
  const file = join(stateDir, "decision-secret");
29
38
  if (!existsSync(file))
30
39
  writeFileSync(file, newSecret(32), { mode: 0o600, flag: "wx" });
@@ -37,7 +46,7 @@ export class DecisionTokens {
37
46
  }
38
47
  /** A token for one card: one request, at the version the card shows, for one person in one client. */
39
48
  issue(recordId, personId, clientId, now = Math.floor(Date.now() / 1000), version = 1) {
40
- const exp = now + DECISION_TOKEN_TTL;
49
+ const exp = now + this.ttl;
41
50
  return `${exp}.v${version}.${this.sign(recordId, personId, clientId, exp, version)}`;
42
51
  }
43
52
  /** `stale`: the token is genuine but was minted for a version the request no longer holds. */
@@ -98,6 +107,10 @@ a{color:inherit}
98
107
  // A redraw while a decision is being recorded must not offer the buttons again.
99
108
  var pending={};
100
109
  var errors={};
110
+ // A decision still running is followed to its outcome (jgwill/Miadi#924): one read every POLL_MS, at most POLL_MAX.
111
+ var POLL_MS=3000,POLL_MAX=100,polls={};
112
+ // An error that leaves the request open offers its approval page under it.
113
+ var relink={};
101
114
  function el(tag,attrs,text){var e=document.createElement(tag);if(attrs)for(var k in attrs){if(k==="class")e.className=attrs[k];else e.setAttribute(k,attrs[k]);}if(text!=null)e.textContent=text;return e;}
102
115
  function host(){return window.openai||null;}
103
116
  function data(){var h=host();return h&&h.toolOutput?h.toolOutput:null;}
@@ -125,7 +138,23 @@ a{color:inherit}
125
138
  if(!out&&res&&res.content&&res.content[0]&&res.content[0].text){try{out=JSON.parse(res.content[0].text);}catch(e){out={message:res.content[0].text};}}
126
139
  return out;
127
140
  }
128
- function projectionView(p,note){return {cls:p.stage==="failed"?"fail":"done",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}
141
+ function projectionView(p,note){return {cls:p.stage==="failed"?"fail":["recording","dispatching"].indexOf(p.stage)!==-1?"note":"done",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}
142
+ // Read where a request stands. Still running: read again later. Never decided (the call was lost on the way): offer the decision again.
143
+ function follow(r,lost){
144
+ if(!host()||!host().callTool)return;
145
+ polls[r.id]=(polls[r.id]||0)+1;
146
+ host().callTool("get_run_result",{record_id:r.id}).then(function(res){
147
+ var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));
148
+ if(!p){if(lost){delete decided[r.id];errors[r.id]=lost;}render();return;}
149
+ if(p.stage==="awaiting_decision"){delete decided[r.id];if(lost)errors[r.id]=lost+" Nothing was decided; decide again.";render();return;}
150
+ var note=decided[r.id]&&decided[r.id].note;
151
+ decided[r.id]=projectionView(p,note);delete errors[r.id];render();
152
+ if(p.stage==="recording"&&polls[r.id]<POLL_MAX)setTimeout(function(){follow(r);},POLL_MS);
153
+ },function(err){
154
+ if(lost){decided[r.id]={cls:"note",stage:"recording",label:"Your decision may be recorded. Checking again…",lines:[],url:null,note:null};render();}
155
+ if(polls[r.id]<POLL_MAX)setTimeout(function(){follow(r,lost);},POLL_MS);
156
+ });
157
+ }
129
158
  function card(r,token){
130
159
  var a=el("article",{"aria-label":r.title});
131
160
  a.appendChild(el("span",{class:"kind"},r.kind_label||r.kind));
@@ -138,6 +167,7 @@ a{color:inherit}
138
167
  if(r.projection&&r.projection.stage!=="awaiting_decision"&&(!decided[r.id]||["recording","dispatching"].indexOf(r.projection.stage)===-1)){decided[r.id]=projectionView(r.projection);}
139
168
  if(decided[r.id]){
140
169
  var shown=decided[r.id];a.appendChild(decidedBlock(shown));
170
+ if(shown.stage==="recording"&&polls[r.id]===undefined&&host()&&host().callTool){polls[r.id]=0;setTimeout(function(){follow(r);},POLL_MS);}
141
171
  if(["recording","dispatching"].indexOf(shown.stage)!==-1&&host()&&host().callTool){
142
172
  var refresh=el("button",{type:"button"},"Refresh status");
143
173
  refresh.addEventListener("click",function(){
@@ -154,11 +184,9 @@ a{color:inherit}
154
184
  if(errors[r.id])a.appendChild(el("p",{class:"fail",role:"status"},errors[r.id]));
155
185
  return a;
156
186
  }
187
+ function approvalLink(){var p=el("p",{class:"note"});p.appendChild(document.createTextNode("Decide on its approval page: "));p.appendChild(el("a",{href:r.approval_url,target:"_blank",rel:"noopener"},"open the approval page"));return p;}
157
188
  if(pending[r.id]){status.className="note";status.textContent="Recording your decision…";a.appendChild(status);return a;}
158
- if(!token||!host()||!host().callTool){
159
- var p=el("p",{class:"note"});p.appendChild(document.createTextNode("Decide on its approval page: "));
160
- var link=el("a",{href:r.approval_url,target:"_blank",rel:"noopener"},"open the approval page");p.appendChild(link);a.appendChild(p);return a;
161
- }
189
+ if(!token||!host()||!host().callTool){a.appendChild(approvalLink());return a;}
162
190
  var ta=el("textarea",{"aria-label":"Instructions or changes (needed to approve with changes or send back)",placeholder:"Instructions or changes (needed to approve with changes or send back)"});
163
191
  a.appendChild(ta);
164
192
  var row=el("div",{class:"row"});
@@ -166,7 +194,7 @@ a{color:inherit}
166
194
  var btns=[];
167
195
  buttons.forEach(function(b){var x=el("button",{type:"button",class:b[2]},b[1]);x.addEventListener("click",function(){go(b[0]);});btns.push(x);row.appendChild(x);});
168
196
  a.appendChild(row);a.appendChild(status);
169
- if(errors[r.id]){status.className="fail";status.textContent=errors[r.id];}
197
+ if(errors[r.id]){status.className="fail";status.textContent=errors[r.id];if(relink[r.id])a.appendChild(approvalLink());}
170
198
  function go(choice){
171
199
  var text=ta.value.trim();
172
200
  if((choice==="approve_with_changes"||choice==="request_revision")&&!text){status.className="fail";status.textContent=choice==="request_revision"?"Say what to revise.":"Write the changes to apply.";ta.focus();return;}
@@ -174,14 +202,22 @@ a{color:inherit}
174
202
  host().callTool("decide_request",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){
175
203
  var out=toolResult(res);
176
204
  // The connector answers with the request's projection, also when a click finds it decided already.
177
- var proj=out&&out.projection;
178
- var already=!!(out&&out.error==="already_decided");
179
- var ok=out&&!out.error;
205
+ var proj=out&&(out.projection||(out.detail&&out.detail.projection));
206
+ var code=out&&out.error;
180
207
  delete pending[r.id];
181
- if(proj){decided[r.id]=projectionView(proj,already?"This request was already decided. Here is where it stands.":null);}
182
- else{decided[r.id]={cls:ok?"done":"fail",label:ok?(out.summary||"Recorded."):((out&&out.message)||"Not recorded."),lines:[],url:null,note:null};}
208
+ // The token expired or the request was revised, and it still awaits a decision: say so and keep it open.
209
+ if(code&&(!proj||proj.stage==="awaiting_decision")){errors[r.id]=(out&&out.message)||"Not recorded.";relink[r.id]=true;render();return;}
210
+ var note=out&&out.replayed?"This decision was already recorded. Nothing ran again.":code==="decision_conflict"?"A different decision was recorded first. Here is where it stands.":code==="already_decided"?"This request was already decided. Here is where it stands.":null;
211
+ if(proj){decided[r.id]=projectionView(proj,note);}
212
+ else{decided[r.id]={cls:code?"fail":"done",label:code?((out&&out.message)||"Not recorded."):((out&&out.summary)||"Recorded."),lines:[],url:null,note:null};}
183
213
  render();
184
- },function(err){delete pending[r.id];errors[r.id]="Not recorded: "+(err&&err.message?err.message:String(err));render();});
214
+ },function(err){
215
+ // The host gave up waiting; the connector may still have recorded it. Read before offering the buttons again (#924).
216
+ delete pending[r.id];
217
+ decided[r.id]={cls:"note",stage:"recording",label:"Checking whether your decision was recorded…",lines:[],url:null,note:null};
218
+ polls[r.id]=0;render();
219
+ follow(r,"Not recorded: "+(err&&err.message?err.message:String(err))+".");
220
+ });
185
221
  }
186
222
  return a;
187
223
  }
@@ -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>;
@@ -0,0 +1,150 @@
1
+ import { contentHash, versionOf } from "./revisions.js";
2
+ /** Providers a request may name only with the requester's own per-mission choice. */
3
+ export const CHOICE_PROVIDERS = ["codex"];
4
+ export const CODEX_POLICIES = ["per_mission_choice", "selectable"];
5
+ export const DEFAULT_CODEX_POLICY = "per_mission_choice";
6
+ /** How long a per-mission choice stays good for the approval that follows it. */
7
+ export const CHOICE_TTL_MS = 24 * 60 * 60 * 1000;
8
+ export function needsPersonChoice(provider) {
9
+ return typeof provider === "string" && CHOICE_PROVIDERS.includes(provider);
10
+ }
11
+ /** Whether a person id may make a choice or set a policy: a person, never a service token. */
12
+ export function isPersonId(personId) {
13
+ return typeof personId === "string" && personId.length > 0 && !personId.startsWith("system:");
14
+ }
15
+ /** A person's provider policy: the latest one they set themself, else the default. */
16
+ export function providerPolicyOf(records, personId) {
17
+ let latest;
18
+ for (const r of records) {
19
+ if (r.kind !== "provider_policy" || r.actor.person_id !== personId || !isPersonId(r.actor.person_id))
20
+ continue;
21
+ if (!CODEX_POLICIES.includes(String(r.body.codex)))
22
+ continue;
23
+ latest = r;
24
+ }
25
+ if (!latest)
26
+ return { person_id: personId, codex: DEFAULT_CODEX_POLICY, source: "default" };
27
+ return { person_id: personId, codex: latest.body.codex, source: "person", record: latest.id, set_at: latest.created_at };
28
+ }
29
+ function str(v) {
30
+ return typeof v === "string" && v ? v : null;
31
+ }
32
+ /**
33
+ * Whether the provider a request names may launch, and why. Pure: the ledger is read,
34
+ * nothing is written. `requireFresh` (the approval) also refuses a choice older than
35
+ * CHOICE_TTL_MS; the brief written for an approved request checks the binding only.
36
+ */
37
+ export function authorizeProvider(record, records, opts = {}) {
38
+ const body = (record.body ?? {});
39
+ const requested = str(body.provider);
40
+ const provider = str(body.resolved_provider) ?? requested;
41
+ const requester = {
42
+ person_id: record.actor.person_id,
43
+ name: record.actor.name,
44
+ ...(record.actor.client_name ? { client: record.actor.client_name } : {}),
45
+ };
46
+ const policy = providerPolicyOf(records, record.actor.person_id);
47
+ const policyView = { codex: policy.codex, source: policy.source, ...(policy.record ? { record: policy.record, set_at: policy.set_at } : {}) };
48
+ const base = { provider, requested, requester, policy: policyView };
49
+ if (!needsPersonChoice(provider)) {
50
+ return {
51
+ ...base,
52
+ state: "not_required",
53
+ authorized: true,
54
+ explanation: provider
55
+ ? `${provider} needs no per-mission choice; it was ${requested ? "named in the request" : "picked by the host's preference order"} and verified installed.`
56
+ : "No provider is named: the factory runner's configured default launches, and the runner never starts Codex for a connector brief without the brief's own authorization.",
57
+ };
58
+ }
59
+ if (policy.codex === "selectable" && isPersonId(record.actor.person_id)) {
60
+ return {
61
+ ...base,
62
+ state: "policy_selectable",
63
+ authorized: true,
64
+ explanation: `${record.actor.name}'s own provider policy (set ${policy.set_at}) lets their requests name Codex without a per-mission choice. They can change it on the connector's providers page.`,
65
+ };
66
+ }
67
+ const now = opts.now ?? Date.now();
68
+ const version = versionOf(record);
69
+ const hash = contentHash(record);
70
+ const notBinding = [];
71
+ let choice;
72
+ for (const r of records) {
73
+ if (r.kind !== "provider_choice")
74
+ continue;
75
+ const b = (r.body ?? {});
76
+ if (b.request_id !== record.id)
77
+ continue;
78
+ const why = b.provider !== provider
79
+ ? `it chose ${String(b.provider)}, not ${provider}`
80
+ : r.actor.person_id !== record.actor.person_id || !isPersonId(r.actor.person_id)
81
+ ? `${r.actor.name} is not the requester`
82
+ : b.version !== version || b.content_hash !== hash
83
+ ? `it was made for version ${String(b.version)}; the request is at version ${version}`
84
+ : opts.requireFresh && now - Date.parse(r.created_at) > CHOICE_TTL_MS
85
+ ? `it is older than ${CHOICE_TTL_MS / 3_600_000} hours`
86
+ : null;
87
+ if (why)
88
+ notBinding.push({ record: r.id, why });
89
+ else
90
+ choice = r;
91
+ }
92
+ const extra = notBinding.length ? { not_binding: notBinding } : {};
93
+ if (choice) {
94
+ const b = choice.body;
95
+ return {
96
+ ...base,
97
+ ...extra,
98
+ state: "person_choice",
99
+ authorized: true,
100
+ choice: {
101
+ record: choice.id,
102
+ provider: String(b.provider),
103
+ by: { person_id: choice.actor.person_id, name: choice.actor.name },
104
+ at: choice.created_at,
105
+ version: Number(b.version),
106
+ content_hash: String(b.content_hash),
107
+ ...(choice.actor.client_id ? { via: choice.actor.client_id } : {}),
108
+ },
109
+ explanation: `${choice.actor.name} chose Codex for this mission (version ${version}) on its approval page at ${choice.created_at}. The choice covers this mission only.`,
110
+ };
111
+ }
112
+ if (!isPersonId(record.actor.person_id)) {
113
+ return {
114
+ ...base,
115
+ ...extra,
116
+ state: "needs_person_choice",
117
+ authorized: false,
118
+ explanation: `The request names Codex and was made with a service token (${record.actor.name}). Only a person chooses Codex, for a request of their own, so this request cannot run Codex. Name another provider.`,
119
+ };
120
+ }
121
+ return {
122
+ ...base,
123
+ ...extra,
124
+ state: "needs_person_choice",
125
+ authorized: false,
126
+ explanation: `The request names Codex${requester.client ? `, asked through ${requester.client}` : ""}. Codex runs only when ${record.actor.name} chooses it for this mission on its approval page; approving the request, on the page or on a card, does not choose it. Name another provider to run without that step.`,
127
+ };
128
+ }
129
+ /** The authorization as the brief carries it to the factory runner: attributes it checks before launching. */
130
+ export function briefAttributes(a) {
131
+ if (!needsPersonChoice(a.provider))
132
+ return {};
133
+ if (a.state === "person_choice" && a.choice)
134
+ return { provider_authorization: "person_choice", provider_choice: a.choice.record };
135
+ if (a.state === "policy_selectable" && a.policy.record)
136
+ return { provider_authorization: "policy_selectable", provider_policy: a.policy.record };
137
+ return {};
138
+ }
139
+ /** The authorization as a receipt keeps it: no body text, only who, what and which record. */
140
+ export function authorizationReceipt(a) {
141
+ return {
142
+ state: a.state,
143
+ authorized: a.authorized,
144
+ provider: a.provider,
145
+ requested: a.requested,
146
+ requester: a.requester,
147
+ policy: a.policy,
148
+ ...(a.choice ? { choice: a.choice } : {}),
149
+ };
150
+ }
@@ -8,7 +8,11 @@ export interface ProviderSpec {
8
8
  /** Config paths whose existence suggests a sign-in; contents are never read. */
9
9
  auth_evidence: string[];
10
10
  }
11
- /** Preference order when a run request names no provider (jgwill/Miadi#893). */
11
+ /**
12
+ * Preference order when a run request names no provider (jgwill/Miadi#893). Codex is never
13
+ * picked by default: a request names it, and it runs only on the requester's own choice for
14
+ * that mission or their own policy (provider-policy.ts, jgwill/Miadi#926).
15
+ */
12
16
  export declare const PROVIDERS: ProviderSpec[];
13
17
  export interface ProviderAvailability {
14
18
  id: ProviderId;
@@ -38,8 +42,8 @@ export type ProviderResolution = {
38
42
  /**
39
43
  * Resolve the provider a run will ask the intake for. A requested provider must
40
44
  * be known and installed; with no request the first installed provider in the
41
- * preference order that may be selected by default wins — Claude Code only ever
42
- * by an explicit request.
45
+ * preference order that may be selected by default wins. Codex and Claude Code
46
+ * only ever by name; whether a named Codex may run is provider-policy.ts's answer.
43
47
  */
44
48
  export declare function resolveProvider(requested: string | undefined, found: ProviderAvailability[]): ProviderResolution;
45
49
  /**