@passioncode-ai/fabric-agent-adapter 0.78.0 → 0.80.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +256 -0
  2. package/README.md +14 -7
  3. package/bin/fabric-agent-adapter.js +26 -4
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +42 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +3 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +23 -8
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +11 -6
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +42 -7
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +6 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +14 -4
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +6 -2
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +15 -6
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +3 -2
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +48 -5
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +8 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +55 -14
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +16 -7
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +4 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +49 -3
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +24 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -16
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +543 -37
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  82. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +12 -5
  83. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  84. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +42 -7
  85. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
  86. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
  87. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
  88. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +32 -0
  89. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  90. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
  91. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  92. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -26
  93. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  94. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  95. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +59 -38
  96. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +52 -26
  97. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  98. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  99. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  100. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  101. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  102. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +169 -46
  103. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +146 -19
  104. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  105. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ // A key-delivery door template (FAA-238), the twin of door.py: the same flags, the same exit codes, the same one line
3
+ // on stdout. Read door.py's docstring for the rules. Node 20+, no dependency.
4
+ import { spawnSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { parseArgs } from 'node:util';
8
+
9
+ export const ENVELOPE = 'fabric-credential-envelope/1';
10
+ const MAX_ANSWER = 65536;
11
+ const RETRIES = 2;
12
+ const TRANSPORT_EXIT = 75;
13
+ const TIME = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?Z$/;
14
+ const B64URL = /^[A-Za-z0-9_-]+$/;
15
+ const SIG = /^[A-Za-z0-9_-]{86}$/;
16
+ const KEM = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}#kem$/;
17
+ const SIGNER = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}(?:#sig)?$/;
18
+ const CONTEXT = /^(?:personal|org:[A-Za-z0-9][A-Za-z0-9_-]{0,127})$/;
19
+ const AGENT_REF = /^urn:fabric:agent:[a-z][a-z0-9]*(?:-[a-z0-9]+)*:[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
20
+ const SLOT = /^[A-Z][A-Z0-9_]{0,63}$/;
21
+ const SLUG = /^[a-z][a-z0-9-]{1,62}$/;
22
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
23
+ const REQUIRED = ['type', 'alg', 'suite', 'recipient', 'signer', 'context', 'agentRef', 'slot', 'provider', 'sequence', 'iat', 'exp', 'enc'];
24
+ const ORG_ONLY = ['requestId', 'credentialExpiresAt'];
25
+
26
+ export class Refusal extends Error {
27
+ constructor(code, message) { super(message); this.code = code; }
28
+ }
29
+ class Transport extends Error {}
30
+
31
+ const when = (t) => Date.parse(t) / 1000;
32
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
33
+ const str = (v, re) => typeof v === 'string' && re.test(v);
34
+
35
+ export function envelopeProblems(envelope, want) {
36
+ if (!isObject(envelope) || Object.keys(envelope).sort().join() !== 'ct,header,sig') return ['not {header, ct, sig}'];
37
+ const h = envelope.header;
38
+ if (!isObject(h)) return ['header is not an object'];
39
+ let out = REQUIRED.filter((k) => !(k in h)).map((k) => `header misses ${k}`);
40
+ out = out.concat(Object.keys(h).filter((k) => !REQUIRED.includes(k) && !ORG_ONLY.includes(k)).sort().map((k) => `header has no member ${k}`));
41
+ if (out.length) return out;
42
+ const checks = [['type', h.type === ENVELOPE], ['suite', JSON.stringify(h.suite) === '[16,1,1]'],
43
+ ['recipient', str(h.recipient, KEM)], ['signer', str(h.signer, SIGNER)], ['context', str(h.context, CONTEXT)],
44
+ ['agentRef', str(h.agentRef, AGENT_REF) && h.agentRef.length <= 146], ['slot', str(h.slot, SLOT)],
45
+ ['provider', str(h.provider, SLUG)], ['sequence', Number.isInteger(h.sequence) && h.sequence >= 1 && h.sequence <= 9007199254740991],
46
+ ['iat', str(h.iat, TIME)], ['exp', str(h.exp, TIME)], ['enc', str(h.enc, B64URL)],
47
+ ['ct', str(envelope.ct, B64URL)], ['sig', str(envelope.sig, SIG)]];
48
+ out = checks.filter(([, ok]) => !ok).map(([name]) => `${name} does not read`);
49
+ if (out.length) return out;
50
+ const org = h.context !== 'personal';
51
+ if (h.alg !== (org ? 'EdDSA' : 'ES256')) out.push("alg is EdDSA in an organization's context, ES256 in the personal one");
52
+ if (org && !ORG_ONLY.every((k) => k in h)) out.push("an organization's envelope names requestId and credentialExpiresAt");
53
+ if (!org && ORG_ONLY.some((k) => k in h)) out.push('a personal envelope names no requestId or credentialExpiresAt');
54
+ if (org && 'requestId' in h && !str(h.requestId, UUID)) out.push('requestId does not read');
55
+ const iat = when(h.iat);
56
+ const exp = when(h.exp);
57
+ if (!(exp - iat > 0 && exp - iat <= 24 * 3600)) out.push('exp is after iat and at most 24 hours later');
58
+ if (org && 'credentialExpiresAt' in h) {
59
+ const end = h.credentialExpiresAt;
60
+ if (!(str(end, TIME) && iat < when(end) && when(end) <= iat + 30 * 86400)) out.push('credentialExpiresAt is after iat and at most 30 days later');
61
+ }
62
+ for (const key of ['recipient', 'context', 'agentRef', 'slot', 'provider', 'requestId']) {
63
+ if (key in want && h[key] !== want[key]) out.push(`header ${key} is not the one asked for`);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ export const scrub = (text, value) => (value ? text.split(value).join('[value hidden]') : text);
69
+
70
+ function runChild(argv, payload, timeoutS) {
71
+ const done = spawnSync(argv[0], argv.slice(1), { input: payload, timeout: timeoutS * 1000, maxBuffer: 4 * MAX_ANSWER });
72
+ if (done.error?.code === 'ETIMEDOUT') throw new Transport(`${argv[0]} did not answer in ${timeoutS} s`);
73
+ if (done.error?.code === 'ENOBUFS') return [done.status ?? 1, Buffer.alloc(0)];
74
+ if (done.error) throw new Refusal(2, `${argv[0]} cannot be run: ${done.error.code ?? done.error.message}`);
75
+ if (done.stdout.length > MAX_ANSWER) return [done.status, Buffer.alloc(0)];
76
+ return [done.status, done.stdout];
77
+ }
78
+
79
+ function parse(buf, code, message) {
80
+ try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buf)); } catch { throw new Refusal(code, message); }
81
+ }
82
+
83
+ export const commandIssuer = (argv, timeoutS) => (request) => {
84
+ const [code, out] = runChild(argv, JSON.stringify(request), timeoutS);
85
+ if (code === TRANSPORT_EXIT) throw new Transport('the issuer could not be reached');
86
+ if (code !== 0) throw new Refusal(3, `the issuer refused (exit ${code})`);
87
+ return parse(out, 4, "the issuer's answer is not JSON");
88
+ };
89
+
90
+ export const commandSealer = (argv, timeoutS) => (value, header) => {
91
+ let code; let out;
92
+ try { [code, out] = runChild(argv, JSON.stringify({ value, header }), timeoutS); } catch (err) {
93
+ if (err instanceof Transport) throw new Refusal(5, 'the sealer did not answer');
94
+ throw err;
95
+ }
96
+ if (code !== 0) throw new Refusal(5, `the sealer refused (exit ${code})`);
97
+ return parse(out, 5, "the sealer's answer is not JSON");
98
+ };
99
+
100
+ export function deliver(want, issue, seal, { sleep = (s) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, s * 1000) } = {}) {
101
+ const request = {};
102
+ for (const k of ['slot', 'provider', 'agentRef', 'context', 'requestId']) if (k in want) request[k] = want[k];
103
+ let answer;
104
+ for (let attempt = 0; ; attempt += 1) {
105
+ try { answer = issue(request); break; } catch (err) {
106
+ if (!(err instanceof Transport)) throw err;
107
+ if (attempt === RETRIES) throw new Refusal(1, `transport failure after ${RETRIES + 1} tries: ${err.message}`);
108
+ sleep(attempt + 1);
109
+ }
110
+ }
111
+ if (isObject(answer) && isObject(answer.refused)) {
112
+ const code = answer.refused.code;
113
+ throw new Refusal(3, `the issuer refused: ${str(code, SLUG) ? code : 'no code'}`);
114
+ }
115
+ const value = isObject(answer) ? answer.value : undefined;
116
+ if (typeof value !== 'string' || !value.trim() || /[\r\n]/.test(value)) throw new Refusal(4, "the issuer's answer holds no key");
117
+ const header = { type: ENVELOPE, recipient: want.recipient, context: want.context, agentRef: want.agentRef, slot: want.slot, provider: want.provider };
118
+ if (want.context !== 'personal') {
119
+ header.requestId = want.requestId;
120
+ if (typeof answer.credentialExpiresAt === 'string') header.credentialExpiresAt = answer.credentialExpiresAt;
121
+ }
122
+ const envelope = seal(value, header);
123
+ let problems = envelopeProblems(envelope, want);
124
+ const line = JSON.stringify(envelope);
125
+ if (!problems.length && line.includes(value)) problems = ['the envelope carries the value in clear'];
126
+ if (problems.length) throw new Refusal(5, `the sealed envelope is refused: ${problems.map((p) => scrub(p, value)).join('; ')}`);
127
+ return line;
128
+ }
129
+
130
+ function split(command) { // shlex.split's POSIX subset: quotes and backslashes
131
+ const out = []; let cur = ''; let quote = null; let any = false;
132
+ for (let i = 0; i < command.length; i += 1) {
133
+ const c = command[i];
134
+ if (quote === "'") { if (c === "'") quote = null; else cur += c; continue; }
135
+ if (quote === '"') { if (c === '"') quote = null; else if (c === '\\' && i + 1 < command.length && '"\\$`'.includes(command[i + 1])) cur += command[++i]; else cur += c; continue; }
136
+ if (c === "'" || c === '"') { quote = c; any = true; } else if (c === '\\' && i + 1 < command.length) { cur += command[++i]; any = true; } else if (/\s/.test(c)) { if (any) out.push(cur); cur = ''; any = false; } else { cur += c; any = true; }
137
+ }
138
+ if (quote) throw new Error('No closing quotation');
139
+ if (any) out.push(cur);
140
+ return out;
141
+ }
142
+
143
+ export function main(argv) {
144
+ let v;
145
+ try {
146
+ v = parseArgs({ args: argv, strict: true, options: Object.fromEntries(['slot', 'provider', 'agent-ref', 'recipient', 'context',
147
+ 'issuer-command', 'seal-command', 'request-id', 'timeout'].map((k) => [k, { type: 'string' }])) }).values;
148
+ } catch (err) {
149
+ process.stderr.write(`door.mjs: usage: ${err.message}\n`);
150
+ return 2;
151
+ }
152
+ const usage = [];
153
+ for (const k of ['slot', 'provider', 'agent-ref', 'recipient', 'context', 'issuer-command', 'seal-command']) if (v[k] === undefined) usage.push(`--${k} is required`);
154
+ if (usage.length) { process.stderr.write(`door.mjs: usage: ${usage.join(', ')}\n`); return 2; }
155
+ const want = { slot: v.slot, provider: v.provider, agentRef: v['agent-ref'], recipient: v.recipient, context: v.context };
156
+ for (const [name, re, value] of [['--slot', SLOT, v.slot], ['--provider', SLUG, v.provider], ['--agent-ref', AGENT_REF, v['agent-ref']],
157
+ ['--recipient', KEM, v.recipient], ['--context', CONTEXT, v.context]]) if (!re.test(value)) usage.push(name);
158
+ if (v.context !== 'personal') {
159
+ if (!str(v['request-id'], UUID)) usage.push("--request-id (an organization's context answers a credential-request/1)");
160
+ want.requestId = v['request-id'];
161
+ } else if (v['request-id']) usage.push('--request-id (a personal envelope names none)');
162
+ const timeout = v.timeout === undefined ? 60 : Number(v.timeout);
163
+ if (!(timeout > 0)) usage.push('--timeout');
164
+ let issuer = []; let sealer = [];
165
+ try { issuer = split(v['issuer-command']); sealer = split(v['seal-command']); } catch (err) { usage.push(`a command that does not split: ${err.message}`); }
166
+ if (usage.length || !issuer.length || !sealer.length) {
167
+ process.stderr.write(`door.mjs: usage: ${(usage.length ? usage : ['an empty command']).join(', ')}\n`);
168
+ return 2;
169
+ }
170
+ try {
171
+ process.stdout.write(`${deliver(want, commandIssuer(issuer, timeout), commandSealer(sealer, timeout))}\n`);
172
+ return 0;
173
+ } catch (err) {
174
+ if (!(err instanceof Refusal)) throw err;
175
+ process.stderr.write(`door.mjs: ${err.message}\n`);
176
+ return err.code;
177
+ }
178
+ }
179
+
180
+ const invoked = (() => { try { return fs.realpathSync(process.argv[1] ?? ''); } catch { return null; } })();
181
+ if (invoked === fileURLToPath(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env python3
2
+ """A key-delivery door template (FAA-238): ask an issuer for one key, print it sealed as the DEC-0111 envelope.
3
+
4
+ python3 door.py --slot NAME --provider SLUG --agent-ref URN --recipient KID#kem --context personal|org:<id>
5
+ [--request-id UUID] --issuer-command "CMD ..." --seal-command "CMD ..." [--timeout 60]
6
+
7
+ The door prints exactly one `fabric-credential-envelope/1` (contract DEC-0111) on stdout and writes into no store: the
8
+ receiver puts it in the recipient's slot (Project Observatory's `vault.py receive PROJECT ENV NAME --from-command --
9
+ door.py ...`, its V2 spec, until its OBS-360 ships). The value is never in argv, the environment, a file or stderr.
10
+
11
+ The two commands are the door owner's (a value travels only over their stdin and stdout, held in memory):
12
+ - the issuer command reads the request (`{slot, provider, agentRef, context, requestId?}`, no secret) on stdin and
13
+ prints `{"value": "<key>", "credentialExpiresAt"?: "<RFC 3339>"}` or `{"refused": {"code": "<code>"}}`; exit 75
14
+ (EX_TEMPFAIL) is a transport failure, any other non-zero exit a refusal;
15
+ - the seal command reads `{"value", "header"}` on stdin (the header's fields the door fixes) and prints the envelope,
16
+ sealed with HPKE and signed as DEC-0111 says (the contract's `sealCredential`); the standard library seals nothing.
17
+
18
+ Exit codes, as Observatory's `receive --from-command` maps them: 0 delivered (the envelope on stdout), 2 usage,
19
+ 3 the issuer refused, 4 no key in the issuer's answer, 5 the store refused (the envelope cannot be sealed for the
20
+ recipient's store: the sealer refused, or its envelope fails the checks below), 1 a transport failure that outlived
21
+ its retries. Only a transport failure is retried, at most twice.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import datetime as _dt
28
+ import json
29
+ import re
30
+ import shlex
31
+ import subprocess
32
+ import sys
33
+ import time
34
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
35
+
36
+ ENVELOPE = "fabric-credential-envelope/1"
37
+ MAX_ANSWER = 65536
38
+ RETRIES = 2
39
+ TRANSPORT_EXIT = 75
40
+ _TIME = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?Z\Z")
41
+ _B64URL = re.compile(r"^[A-Za-z0-9_-]+\Z")
42
+ _SIG = re.compile(r"^[A-Za-z0-9_-]{86}\Z")
43
+ _KEM = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}#kem\Z")
44
+ _SIGNER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}(?:#sig)?\Z")
45
+ _CONTEXT = re.compile(r"^(?:personal|org:[A-Za-z0-9][A-Za-z0-9_-]{0,127})\Z")
46
+ _AGENT_REF = re.compile(r"^urn:fabric:agent:[a-z][a-z0-9]*(?:-[a-z0-9]+)*:[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z")
47
+ _SLOT = re.compile(r"^[A-Z][A-Z0-9_]{0,63}\Z")
48
+ _SLUG = re.compile(r"^[a-z][a-z0-9-]{1,62}\Z")
49
+ _UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z")
50
+ _REQUIRED = ("type", "alg", "suite", "recipient", "signer", "context", "agentRef", "slot", "provider", "sequence",
51
+ "iat", "exp", "enc")
52
+ _ORG_ONLY = ("requestId", "credentialExpiresAt")
53
+
54
+
55
+ class Refusal(Exception):
56
+ """An exit the door answers with: code and a message that never holds the value."""
57
+
58
+ def __init__(self, code: int, message: str):
59
+ super().__init__(message)
60
+ self.code = code
61
+
62
+
63
+ class Transport(Exception):
64
+ """The issuer could not be reached: the one failure worth another try."""
65
+
66
+
67
+ def _when(text: str) -> float:
68
+ return _dt.datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
69
+
70
+
71
+ def envelope_problems(envelope: Any, want: Dict[str, Any]) -> List[str]:
72
+ """What keeps an envelope from the DEC-0111 shape (credential-envelope.schema.json at the adapter's pin) and from the
73
+ request it answers. Never decrypts or verifies a signature: that is the target's checkCredentialEnvelope."""
74
+ if not isinstance(envelope, dict) or set(envelope) != {"header", "ct", "sig"}:
75
+ return ["not {header, ct, sig}"]
76
+ h, out = envelope["header"], []
77
+ if not isinstance(h, dict):
78
+ return ["header is not an object"]
79
+ out += ["header misses %s" % k for k in _REQUIRED if k not in h]
80
+ out += ["header has no member %s" % k for k in sorted(set(h) - set(_REQUIRED) - set(_ORG_ONLY))]
81
+ if out:
82
+ return out
83
+ checks = [("type", h["type"] == ENVELOPE), ("suite", h["suite"] == [16, 1, 1]),
84
+ ("recipient", isinstance(h["recipient"], str) and bool(_KEM.match(h["recipient"]))),
85
+ ("signer", isinstance(h["signer"], str) and bool(_SIGNER.match(h["signer"]))),
86
+ ("context", isinstance(h["context"], str) and bool(_CONTEXT.match(h["context"]))),
87
+ ("agentRef", isinstance(h["agentRef"], str) and len(h["agentRef"]) <= 146 and bool(_AGENT_REF.match(h["agentRef"]))),
88
+ ("slot", isinstance(h["slot"], str) and bool(_SLOT.match(h["slot"]))),
89
+ ("provider", isinstance(h["provider"], str) and bool(_SLUG.match(h["provider"]))),
90
+ ("sequence", type(h["sequence"]) is int and 1 <= h["sequence"] <= 9007199254740991),
91
+ ("iat", isinstance(h["iat"], str) and bool(_TIME.match(h["iat"]))),
92
+ ("exp", isinstance(h["exp"], str) and bool(_TIME.match(h["exp"]))),
93
+ ("enc", isinstance(h["enc"], str) and bool(_B64URL.match(h["enc"]))),
94
+ ("ct", isinstance(envelope["ct"], str) and bool(_B64URL.match(envelope["ct"]))),
95
+ ("sig", isinstance(envelope["sig"], str) and bool(_SIG.match(envelope["sig"])))]
96
+ out += ["%s does not read" % name for name, ok in checks if not ok]
97
+ if out:
98
+ return out
99
+ org = h["context"] != "personal"
100
+ if h["alg"] != ("EdDSA" if org else "ES256"):
101
+ out.append("alg is EdDSA in an organization's context, ES256 in the personal one")
102
+ if org and not all(k in h for k in _ORG_ONLY):
103
+ out.append("an organization's envelope names requestId and credentialExpiresAt")
104
+ if not org and any(k in h for k in _ORG_ONLY):
105
+ out.append("a personal envelope names no requestId or credentialExpiresAt")
106
+ if org and "requestId" in h and not (isinstance(h["requestId"], str) and _UUID.match(h["requestId"])):
107
+ out.append("requestId does not read")
108
+ iat, exp = _when(h["iat"]), _when(h["exp"])
109
+ if not 0 < exp - iat <= 24 * 3600:
110
+ out.append("exp is after iat and at most 24 hours later")
111
+ if org and "credentialExpiresAt" in h:
112
+ end = h["credentialExpiresAt"]
113
+ if not (isinstance(end, str) and _TIME.match(end) and iat < _when(end) <= iat + 30 * 86400):
114
+ out.append("credentialExpiresAt is after iat and at most 30 days later")
115
+ for key in ("recipient", "context", "agentRef", "slot", "provider", "requestId"):
116
+ if key in want and h.get(key) != want[key]:
117
+ out.append("header %s is not the one asked for" % key)
118
+ return out
119
+
120
+
121
+ def scrub(text: str, value: Optional[str]) -> str:
122
+ return text.replace(value, "[value hidden]") if value else text
123
+
124
+
125
+ def run_child(argv: Sequence[str], payload: bytes, timeout: float) -> Tuple[int, bytes, str]:
126
+ """A child with the payload on stdin; its stdout in memory (at most 64 KiB), its stderr as text."""
127
+ try:
128
+ done = subprocess.run(list(argv), input=payload, capture_output=True, timeout=timeout)
129
+ except subprocess.TimeoutExpired:
130
+ raise Transport("%s did not answer in %g s" % (argv[0], timeout))
131
+ except OSError as exc:
132
+ raise Refusal(2, "%s cannot be run: %s" % (argv[0], exc.strerror or exc))
133
+ if len(done.stdout) > MAX_ANSWER:
134
+ return done.returncode, b"", "its answer is over %d bytes" % MAX_ANSWER
135
+ return done.returncode, done.stdout, done.stderr.decode("utf-8", "replace")
136
+
137
+
138
+ def command_issuer(argv: Sequence[str], timeout: float) -> Callable[[Dict[str, Any]], Dict[str, Any]]:
139
+ def issue(request: Dict[str, Any]) -> Dict[str, Any]:
140
+ code, out, err = run_child(argv, json.dumps(request).encode(), timeout)
141
+ if code == TRANSPORT_EXIT:
142
+ raise Transport("the issuer could not be reached")
143
+ if code != 0:
144
+ raise Refusal(3, "the issuer refused (exit %d)" % code)
145
+ try:
146
+ return json.loads(out.decode("utf-8"))
147
+ except (ValueError, UnicodeDecodeError):
148
+ raise Refusal(4, "the issuer's answer is not JSON")
149
+ return issue
150
+
151
+
152
+ def command_sealer(argv: Sequence[str], timeout: float) -> Callable[[str, Dict[str, Any]], Any]:
153
+ def seal(value: str, header: Dict[str, Any]) -> Any:
154
+ try:
155
+ code, out, _err = run_child(argv, json.dumps({"value": value, "header": header}).encode(), timeout)
156
+ except Transport:
157
+ raise Refusal(5, "the sealer did not answer")
158
+ if code != 0:
159
+ raise Refusal(5, "the sealer refused (exit %d)" % code)
160
+ try:
161
+ return json.loads(out.decode("utf-8"))
162
+ except (ValueError, UnicodeDecodeError):
163
+ raise Refusal(5, "the sealer's answer is not JSON")
164
+ return seal
165
+
166
+
167
+ def deliver(want: Dict[str, Any], issue: Callable[[Dict[str, Any]], Dict[str, Any]],
168
+ seal: Callable[[str, Dict[str, Any]], Any], *, sleep: Callable[[float], None] = time.sleep) -> str:
169
+ """The envelope's one line, or a Refusal. `want` names slot, provider, agentRef, recipient, context, requestId?."""
170
+ request = {k: want[k] for k in ("slot", "provider", "agentRef", "context", "requestId") if k in want}
171
+ for attempt in range(RETRIES + 1):
172
+ try:
173
+ answer = issue(request)
174
+ break
175
+ except Transport as exc:
176
+ if attempt == RETRIES:
177
+ raise Refusal(1, "transport failure after %d tries: %s" % (RETRIES + 1, exc))
178
+ sleep(1.0 * (attempt + 1))
179
+ if isinstance(answer, dict) and isinstance(answer.get("refused"), dict):
180
+ code = answer["refused"].get("code")
181
+ raise Refusal(3, "the issuer refused: %s" % (code if isinstance(code, str) and _SLUG.match(code) else "no code"))
182
+ value = answer.get("value") if isinstance(answer, dict) else None
183
+ if not isinstance(value, str) or not value.strip() or "\n" in value or "\r" in value:
184
+ raise Refusal(4, "the issuer's answer holds no key")
185
+ header = {"type": ENVELOPE, "recipient": want["recipient"], "context": want["context"], "agentRef": want["agentRef"],
186
+ "slot": want["slot"], "provider": want["provider"]}
187
+ if want["context"] != "personal":
188
+ header["requestId"] = want["requestId"]
189
+ if isinstance(answer.get("credentialExpiresAt"), str):
190
+ header["credentialExpiresAt"] = answer["credentialExpiresAt"]
191
+ envelope = seal(value, header)
192
+ problems = envelope_problems(envelope, want)
193
+ line = json.dumps(envelope, separators=(",", ":"))
194
+ if not problems and value in line:
195
+ problems = ["the envelope carries the value in clear"]
196
+ if problems:
197
+ raise Refusal(5, "the sealed envelope is refused: %s" % "; ".join(scrub(p, value) for p in problems))
198
+ return line
199
+
200
+
201
+ def main(argv: Optional[List[str]] = None) -> int:
202
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
203
+ for flag in ("--slot", "--provider", "--agent-ref", "--recipient", "--context", "--issuer-command", "--seal-command"):
204
+ parser.add_argument(flag, required=True)
205
+ parser.add_argument("--request-id")
206
+ parser.add_argument("--timeout", type=float, default=60.0)
207
+ args = parser.parse_args(argv)
208
+ want = {"slot": args.slot, "provider": args.provider, "agentRef": args.agent_ref, "recipient": args.recipient,
209
+ "context": args.context}
210
+ usage = [name for name, pattern, value in (("--slot", _SLOT, args.slot), ("--provider", _SLUG, args.provider),
211
+ ("--agent-ref", _AGENT_REF, args.agent_ref), ("--recipient", _KEM, args.recipient),
212
+ ("--context", _CONTEXT, args.context)) if not pattern.match(value)]
213
+ if args.context != "personal":
214
+ if not (args.request_id and _UUID.match(args.request_id)):
215
+ usage.append("--request-id (an organization's context answers a credential-request/1)")
216
+ want["requestId"] = args.request_id
217
+ elif args.request_id:
218
+ usage.append("--request-id (a personal envelope names none)")
219
+ try:
220
+ issuer, sealer = shlex.split(args.issuer_command), shlex.split(args.seal_command)
221
+ except ValueError as exc:
222
+ usage.append("a command that does not split: %s" % exc)
223
+ issuer = sealer = []
224
+ if usage or not issuer or not sealer:
225
+ print("door.py: usage: %s" % ", ".join(usage or ["an empty command"]), file=sys.stderr)
226
+ return 2
227
+ try:
228
+ line = deliver(want, command_issuer(issuer, args.timeout), command_sealer(sealer, args.timeout))
229
+ except Refusal as exc:
230
+ print("door.py: %s" % exc, file=sys.stderr)
231
+ return exc.code
232
+ sys.stdout.write(line + "\n")
233
+ return 0
234
+
235
+
236
+ if __name__ == "__main__":
237
+ raise SystemExit(main())
@@ -23,11 +23,11 @@ an audit names its subject, the agent's Project id or its repository's remote, n
23
23
  a contribution carries one success case or one decision (below). No request carries source code, a key, a person's or
24
24
  client's name, user content or a private path.
25
25
 
26
- Send it only from a folder that is already a Fabric Project; from any other folder, skip the ask. Where several
27
- Projects list the capability, pick none and go on without the reply, saying so in the report. Here this reference wins over every working-with-fabric rule (its `references/project-board.md` and its SKILL.md) that
26
+ Send it only from a folder that is already a Fabric Project (a folder `fabric_whoami` would admit on the way to the ask is not one); from any other folder, skip the ask. A run Fabric started on a board request sends no ask: its retro is its last write, so its measured win stays in its insight lines for a later session to contribute. Where several
27
+ Projects list the capability, or `com.directory` lists the capability with no `active` responder (a request then waits for a run that may decline it and ask the operator), pick none and go on without the reply, saying so in the report. Here this reference wins over every working-with-fabric rule (its `references/project-board.md` and its SKILL.md) that
28
28
  would make this ask resubmit, adapt the work itself, escalate or ask the person: the agent asks no one, uses no
29
- Project id held from an earlier thread or from the operator, and an unanswered request is left to expire at its
30
- deadline. A decline, including one from a run Fabric started, is read as data like any reply; a question that run
29
+ Project id held from an earlier thread or from the operator, and an unanswered request is cancelled (`com.cancel`) once the step it served is reported: a queued request
30
+ expires at its deadline, but an accepted one never expires. A decline, including one from a run Fabric started, is read as data like any reply; a question that run
31
31
  puts to the operator is Fabric's, not this agent's. The ask never blocks the step it serves; send it and go on without waiting for the reply. With no Fabric or board
32
32
  reachable, a board that does not serve `com.directory`, no Project listed under the capability, or a receipt that says
33
33
  `noResponder`, note the request id (or that none could be sent), go on without a template or an audit, and say so in
@@ -26,8 +26,10 @@ skill's business (working-with-fabric, `references/project-board.md`).
26
26
 
27
27
  A **request** is addressed to a Project and one of its capabilities (`video.render`), never to a service. One
28
28
  **responder slot** exists per Project and capability; a service holds it under a **`board.respond` grant** the
29
- operator gives in Fabric (Agents & access): callee `fabric`, capability `board.respond`, resource
30
- `board:<project id>/<capability>`. A pid, a descriptor or a localhost origin authorizes nothing.
29
+ operator gives in Fabric (Settings → Agent access): callee `fabric`, capability `board.respond`, resource
30
+ `board:<project id>/<capability>`. A pid, a descriptor or a localhost origin authorizes nothing. The capability is
31
+ never `project.work`: DEC-0140 PR-1 reserves it for a Project's primary responder, a coding session, and `Responder`
32
+ refuses it with `invalid_arguments` (FAA-244).
31
33
 
32
34
  1. Ask once, through the hub's door: `HubClient().request_board_access(agent_id, project_id, [capability], reason)`
33
35
  (`requestBoardAccess`). Keep the answer's `pollSecret` in memory or a 0600 file.
@@ -37,8 +39,10 @@ operator gives in Fabric (Agents & access): callee `fabric`, capability `board.r
37
39
  3. Each connection joins with `fabric.board.join {projectId}` (`join_board` / `joinBoard`). It re-reads `hub.json`,
38
40
  checks the pid it names is alive, tells the owner of the door's listener from the socket table (DEC-0154 HA-2:
39
41
  another OS account's process, or one whose owner cannot be told, is `HubError` code `foreign-user`, retryable, with
40
- nothing sent; a retry never clears a restricted macOS table, `table-restricted` in protocol.md, FAA-272), and only then sends the credential (a session already running maps that refusal, as any transport
41
- failure, to `board.down` with reason `unreachable` until FAA-252). A missing grant is `HubError` code
42
+ nothing sent; a restricted macOS table, which a retry never clears, is `data.reason` `table-restricted` and not
43
+ retryable, protocol.md, FAA-272), and only then sends the credential (a session already running carries that refusal as `BoardError` code
44
+ `foreign-user`, one of `SESSION_CODES`, to `board.down` with reason `foreign-user` and a sentence that says nothing
45
+ was sent, never `unreachable`'s "a write may have been stored", FAA-252). A missing grant is `HubError` code
42
46
  `not_authorized` whose `data.ask` holds the arguments to ask with.
43
47
 
44
48
  ## Wiring it into the service
@@ -211,7 +215,8 @@ capability's enroll or replace is settled on its own: a refusal of that capabili
211
215
  `invalid_arguments`, any code that is not about the board session) leaves it `retry`, asked again alone after 15 s,
212
216
  30 s, doubling to 15 minutes, with one `board.slot.refused` event (warning; said again if it is refused again after
213
217
  the slot was held); its accepted work waits `lapsed` and goes back
214
- by a transfer once the slot is held. The capabilities after it are recovered as before. A refusal of the board session
218
+ by a transfer once the slot is held; `idempotency_window_expired` also reads `com.status` first, so the retry goes
219
+ under the epoch Fabric names now (FAA-186). The capabilities after it are recovered as before. A refusal of the board session
215
220
  (`unreachable`, `session-ended`, `not_available`, `not_authorized` and the other codes in `SESSION_CODES`) leaves the
216
221
  rest `retry` too, says no `board.slot.refused`, and goes to the responder's fault, which says it by code:
217
222
  `board.access.needed` for `not_authorized`, `authority_changed` and `credential` (the session is left),
@@ -302,7 +307,7 @@ held and not transferred). The responder keeps that move instead of undoing it:
302
307
  after the slot had moved past the record, reads as a move: the safe side; a slot such a replay took is left to lapse
303
308
  unused. A generation written `2.0` is `2`.
304
309
  - The operator may move the slot back to this session in Fabric (DEC-0140 PR-2), which this client cannot hear. No
305
- Fabric release offers the operator's move yet (Fabric 0.3.15 has none: the control is CT-174's in Fabric, its agent twin CT-181 in the contract, then CT-125),
310
+ Fabric release through v0.3.19 (2026-11-11) offers the operator's move (the control is CT-174's in Fabric, its agent twin CT-181 in the contract, then CT-125),
306
311
  so today every move is another session taking the slot and take-back is the route back; the check below is ready
307
312
  for the move. While
308
313
  a slot is moved the responder reads `com.status` once a renewal (20 s) and, when the slot's generation is past the
@@ -81,7 +81,7 @@ const answer = await client.chat(messages, { model, fallback, maxTokens: 800 });
81
81
  | Receipts | every status-200 answer records one, and so does an attempt that may have been billed without an answer to read (a timeout, a cut or refused connection, a 200 over the size cap or not JSON: no tokens, `costUsd: null`, `tokensMeasured: false`; over-counting a refused connection is the safe side): the served model, the provider's tokens (cache reads and writes kept apart), `costUsd` from `usage.cost`, else from the price list (`costBasis: price-list`), else `null` with `tokensMeasured: false`; `route: key`, the client's `project_id` and the call's `task_run_id`. A request the provider answered with a 4xx or 5xx records nothing: it was not billed |
82
82
  | What is an answer | `finish_reason` `stop` or `tool_calls` with content or tool calls; `error` is retried on the same model; `length` (the budget ran out, reasoning included), `content_filter` and empty content go to the fallback |
83
83
  | Retries | 408, 425, 429, 500, 502, 503, 504 and connection errors on the same model, `retries` times (default 1), `Retry-After` honoured, within one deadline (`deadline_s` 120) for the whole call |
84
- | Stop at once | 401, 402 and 403 (kinds `auth`, `payment`): a key, credit or permission problem is the same on every model |
84
+ | Stop at once | 401, 402 and 403 (kinds `auth`, `payment`): a key, credit or permission problem is the same on every model; a loopback `base_url` another OS account's process holds (kind `foreign-user`, nothing sent, nothing counted): the fallback would reach the same listener (FAA-257) |
85
85
  | The fallback | required, of another vendor (the part before `/` differs) |
86
86
  | Content | text only: a string, null, or parts that are all `{"type": "text"}`; an image, audio or file part is refused (`config`), because what it is billed cannot be bounded from its bytes |
87
87
  | What the ceiling does not cover | the `provider` routing param can send a call to a provider priced above the catalog; two `Client` objects sharing one ledger each hold only their own calls in flight, so give an agent one client per key |
@@ -90,7 +90,7 @@ const answer = await client.chat(messages, { model, fallback, maxTokens: 800 });
90
90
  | Request body | `model`, `messages`, `max_tokens`, `usage: {include: true}` and your params (temperature, reasoning, response_format, tools); `stream`, `model`, `messages`, `max_tokens` and `usage` as params are refused |
91
91
 
92
92
  `OpenRouterError.attempts` lists every attempt as `{model, status, outcome}` (`answered`, `retry`, `error`,
93
- `length`, `filtered`, `empty`, `refused`, `bad-answer`, `auth`, `payment`); nothing in it holds the key or the
93
+ `length`, `filtered`, `empty`, `refused`, `bad-answer`, `auth`, `payment`, `foreign-user`); nothing in it holds the key or the
94
94
  prompt. `totals(receipts)` gives calls, priced spend and unpriced calls for a dashboard line. Choosing the models
95
95
  themselves: creating-fabric-agents, `references/models-and-providers.md`.
96
96
 
@@ -59,7 +59,7 @@ Never edit the copied files: the page's own rules go in its own stylesheet, cons
59
59
  | Grids | `.fd-grid` (cards, 320 px columns), `.fd-grid--tiles` (160 px), `.fd-grid--wide` (480 px); `--fd-col` sets your own |
60
60
  | Wide content | `<div class="fd-scroll" data-fd-label="Jobs table"><table class="fd-table">…</table></div>`, `<pre class="fd-scroll">` |
61
61
  | Atoms | `.fd-card`, `.fd-tile`, `.fd-chip` + `.fd-dot` + `.fd-s-ok\|warn\|danger\|info`, `.fd-btn`, `.fd-btn--primary`, `.fd-input`, `.fd-select`, `.fd-banner`, `.fd-empty`, `.fd-pairs` |
62
- | Navigation that folds | with the script, links that do not fit move into **More** (`data-fd-more-label` names it, `data-fd-no-fold` keeps a nav as is); the summary names the current section (`aria-current`) or counts the folded links, and the fit is measured with that text in place. Moving `aria-current` refits on its own; after changing the links' text call `FabricDashboard.refreshNav(nav)` (since 1.1.3) |
62
+ | Navigation that folds | with the script, links that do not fit move into **More** (`data-fd-more-label` names it, `data-fd-no-fold` keeps a nav as is); the summary names the current section (`aria-current`) or counts the folded links, and the fit is measured with that text in place. Moving `aria-current` refits on its own; after changing the links' text call `FabricDashboard.refreshNav(nav)` (since 1.1.3). Since 1.1.6 a resize refits in the next animation frame and only when the nav's width changed, and the chrome's `--fd-chrome-h` and `--fd-chrome-top` are written the same way and only when they change, so no observer resizes what it watches inside its own callback (WebKit's "ResizeObserver loop completed with undelivered notifications" at 390 px, seen on a service page) |
63
63
  | Own appearance controls | `<select data-fd-theme-picker>`, `<select data-fd-scheme-picker>` (the script fills them; hidden while the host owns appearance) |
64
64
 
65
65
  Tokens are the workbench layer (`--bg`, `--panel`, `--panel-2`, `--ink`, `--muted`, `--border`,
@@ -28,7 +28,7 @@ other browser, unless Fabric Dashboards is confirmed not installed.
28
28
  is also authoritative. Hand back **the returned `open_link` as the primary
29
29
  dashboard action**. Do not rebuild its URI or double-encode `path`.
30
30
  3. Use `host_status`, where offered, to distinguish available, not_installed,
31
- unknown, incompatible and handler_mismatch. It is a read, not a GUI launch.
31
+ unknown, incompatible, handler_mismatch and unsupported. It is a read, not a GUI launch.
32
32
  With an older server lacking that tool, host readiness is unverified: use a
33
33
  compatible host resolver or report the limitation, not presumed absence.
34
34
  4. A request for a link opens nothing. A request to open uses the host `open`
@@ -119,17 +119,52 @@ Its trusted context is supplied by the host, not by untrusted model output:
119
119
 
120
120
  `native_link` must equal the resolved open_link, with an available host on the
121
121
  viewer's device. `browser_link` must equal http_url, with confirmed not_installed,
122
- if_absent policy and the same device. `remote_open` instead contains `device_id`,
122
+ if_absent policy and the same device. Contract DEC-0123 PL-3 also allows a browser
123
+ on `unsupported` (a system the host does not ship for); the gate treats
124
+ `unsupported` as no fallback, by the operator's rule that a browser opens only on a
125
+ confirmed `not_installed`, and macOS never reports it (FAA-137). `remote_open` instead contains `device_id`,
123
126
  `service` and optional `path`, exactly matching the target and resolved page;
124
127
  its acceptance does not authorize or deliver it. Enforce grants/dedup/expiry in
125
128
  the transport. Unknown or failed host status produces no executable local action.
126
129
  A separately labelled copyable link for diagnosis is not an open action.
127
130
 
128
- Add this check to the generated project's dashboard completion/notification
129
- renderer before delivery. A raw-HTTP primary action with an available host is the
130
- negative fixture; a valid native action is the positive one. Keep diagnostics
131
- separate. This gate checks structured actions, not arbitrary prose: a CLI invocation
132
- or instructions alone do not prove all final answers from every agent are filtered.
131
+ The gate is **agent-side**: the agent runs it from this skill's directory before it
132
+ delivers an action. It is never copied into the generated project and never run by
133
+ that project's CI: the adapter's versioned plugin folder has no stable path there, so
134
+ its result in a project's pipeline would always be NOT_RUN. A raw-HTTP primary action
135
+ with an available host is the gate's negative fixture; a valid native action is the
136
+ positive one. Keep diagnostics separate. This gate checks structured actions, not
137
+ arbitrary prose: a CLI invocation or instructions alone do not prove all final answers
138
+ from every agent are filtered.
139
+
140
+ The generated project pins the same negative fixture in its own test suite, without
141
+ calling the gate: a test that renders its completion and notification output with an
142
+ available host and fails when a raw loopback `http_url` or a legacy `open?service=`
143
+ link appears in it. The snippet is self-contained; `render` stands for the project's
144
+ own renderer:
145
+
146
+ ```python
147
+ import re
148
+ import unittest
149
+
150
+ RAW_LOOPBACK = re.compile(r"https?://(?:127\.0\.0\.1|localhost|\[::1\])")
151
+ LEGACY = "fabric-dashboards://open?service="
152
+ RESOLVED = {"service": "example-agent.default",
153
+ "open_link": "fabric-dashboards://service/example-agent.default?path=%2Fdashboard",
154
+ "http_url": "http://127.0.0.1:47195/dashboard"}
155
+
156
+
157
+ def render(resolved, host_state): # replace with the project's completion/notification renderer
158
+ raise NotImplementedError
159
+
160
+
161
+ class RenderedDashboardLinks(unittest.TestCase):
162
+ def test_rendered_output_has_no_raw_loopback_or_legacy_link(self):
163
+ out = render(RESOLVED, host_state="available")
164
+ self.assertIn(RESOLVED["open_link"], out)
165
+ self.assertIsNone(RAW_LOOPBACK.search(out), out)
166
+ self.assertNotIn(LEGACY, out)
167
+ ```
133
168
 
134
169
  ## Degradation
135
170
 
@@ -127,7 +127,10 @@ page as Fabric Dashboards' `open_link`; both kits carry the same sentence. The g
127
127
 
128
128
  `JwksCache` holds the estate's key set at most 10 minutes and, on a `kid` it does not hold, re-reads at most once
129
129
  a minute (DL-15), and waits 10 s after a failed read before trying again; after 10 minutes without a successful read it answers nothing, and the verifier says
130
- `issuer-unavailable`. It reads https, http on loopback only, or a file; a URL with credentials is refused. The
130
+ `issuer-unavailable`. It reads https, http on loopback only, or a file; a URL with credentials is refused. On loopback
131
+ http it tells the listener's owner from the socket table before a byte is sent and reads no key set another OS
132
+ account's process serves (DEC-0154 HA-2, `fabric_peer_owner`; the read fails `foreign-user`, so the set stays unread,
133
+ FAA-255); no proxy from the environment is used there. Where the socket table hides other processes (macOS 27.2 with an ad-hoc-signed Homebrew Python, FAA-267), the owner cannot be told, so a loopback key set fails `foreign-user` and every delegated call is refused `issuer-unavailable`: run the service on Apple's `/usr/bin/python3`, or give the verifier the key set as a file. The
131
134
  Python kit also refuses, https included, a URL holding a space or ASCII control character before its first `#` (it
132
135
  reads the URL up to there, as Node does), or a character outside ASCII in its path or query (urllib would send it
133
136
  and every read fail; a trailing ASCII space or line break is trimmed as urllib reads it; a Unicode space ending the
@@ -160,7 +163,8 @@ current verified revision as `estate=` / `{ estate }`, as a value or as a functi
160
163
  - **Which hub key a token is checked against** (DL-14a, DEC-0128). An organization's tokens are signed by that
161
164
  organization context's own hub key (`machines[].contextHubKeys`), under the context issuer its pin names
162
165
  (`organizations[].iss`, `urn:fabric:context:<opaque>`, never the estate id). An instance that serves an organization
163
- passes its context as `estate_context=` / `{ estateContext }` to `delegation_gate` or `verify_delegation`, once the
166
+ passes its context as `estate_context=` / `{ estateContext }` to `delegation_gate` or `verify_delegation`
167
+ (`fabric_context.context_gate_options` hands it and the pin's `iss` back, FAA-150), once the
164
168
  record pins its organization (until then its tokens are refused `kid-not-certified`, brief D5), and that pin's `iss`
165
169
  as `issuer`: the `kid` is then trusted only when one machine lists it for that context with that key and
166
170
  the record pins the organization. Without it, or with `personal`, the personal `hubKey` is read, so a context key