@passioncode-ai/fabric-agent-adapter 0.79.1 → 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 (83) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/bin/fabric-agent-adapter.js +26 -4
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +5 -2
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +9 -1
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +5 -2
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -3
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +3 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +41 -3
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +5 -1
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +6 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +38 -9
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +3 -2
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +35 -2
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +20 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +46 -6
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +340 -24
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  62. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -1
  63. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +5 -2
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -1
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +28 -0
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
  69. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  70. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +21 -21
  71. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  72. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  73. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +2 -1
  74. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
  75. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  76. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  77. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  78. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +143 -36
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  83. 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
@@ -39,7 +39,8 @@ refuses it with `invalid_arguments` (FAA-244).
39
39
  3. Each connection joins with `fabric.board.join {projectId}` (`join_board` / `joinBoard`). It re-reads `hub.json`,
40
40
  checks the pid it names is alive, tells the owner of the door's listener from the socket table (DEC-0154 HA-2:
41
41
  another OS account's process, or one whose owner cannot be told, is `HubError` code `foreign-user`, retryable, with
42
- 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 carries that refusal as `BoardError` 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
43
44
  `foreign-user`, one of `SESSION_CODES`, to `board.down` with reason `foreign-user` and a sentence that says nothing
44
45
  was sent, never `unreachable`'s "a write may have been stored", FAA-252). A missing grant is `HubError` code
45
46
  `not_authorized` whose `data.ask` holds the arguments to ask with.
@@ -214,7 +215,8 @@ capability's enroll or replace is settled on its own: a refusal of that capabili
214
215
  `invalid_arguments`, any code that is not about the board session) leaves it `retry`, asked again alone after 15 s,
215
216
  30 s, doubling to 15 minutes, with one `board.slot.refused` event (warning; said again if it is refused again after
216
217
  the slot was held); its accepted work waits `lapsed` and goes back
217
- 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
218
220
  (`unreachable`, `session-ended`, `not_available`, `not_authorized` and the other codes in `SESSION_CODES`) leaves the
219
221
  rest `retry` too, says no `board.slot.refused`, and goes to the responder's fault, which says it by code:
220
222
  `board.access.needed` for `not_authorized`, `authority_changed` and `credential` (the session is left),
@@ -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,7 +119,10 @@ 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.
@@ -163,7 +163,8 @@ current verified revision as `estate=` / `{ estate }`, as a value or as a functi
163
163
  - **Which hub key a token is checked against** (DL-14a, DEC-0128). An organization's tokens are signed by that
164
164
  organization context's own hub key (`machines[].contextHubKeys`), under the context issuer its pin names
165
165
  (`organizations[].iss`, `urn:fabric:context:<opaque>`, never the estate id). An instance that serves an organization
166
- 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
167
168
  record pins its organization (until then its tokens are refused `kid-not-certified`, brief D5), and that pin's `iss`
168
169
  as `issuer`: the `kid` is then trusted only when one machine lists it for that context with that key and
169
170
  the record pins the organization. Without it, or with `personal`, the personal `hubKey` is read, so a context key
@@ -98,6 +98,15 @@ gate = fd.delegation_gate(audience=..., jwks=keys, issuer=pin["iss"], effects=..
98
98
  estate_context="org:acme-rehearsal") # the context fabric_context started it for
99
99
  ```
100
100
 
101
+ `fabric_context.context_gate_options(info, record)` / `contextGateOptions(info, record)` hands both back from the
102
+ instance `hold_context_instance` started (FAA-150): `estate_context` always, and `issuer` when the record pins that
103
+ organization; spread it after the estate issuer, so a personal instance keeps the estate's:
104
+
105
+ ```python
106
+ gate = fd.delegation_gate(audience=..., jwks=keys, effects=..., estate=lambda: current,
107
+ **{"issuer": estate_issuer, **fabric_context.context_gate_options(info, current)})
108
+ ```
109
+
101
110
  The `kid` is then certified only when exactly one machine lists it for that context with the published key and the
102
111
  revision pins the organization; omitted or `personal`, the personal `hubKey` is read. A context key never verifies a
103
112
  personal token, the personal key never an organization's, and a context that is neither (`Org:x`, a trailing space, a
@@ -218,7 +227,9 @@ const { dirs, instance, lock } = await holdContextInstance('example-agent', 'org
218
227
  kept; another context or instance, or unreadable, releases the lock and refuses. The folder never changes context.
219
228
 
220
229
  A refusal is one sentence on stderr naming the code and the context, never a record or grant value, and exit 78
221
- (`EXIT_CONTEXT_REFUSED`, EX_CONFIG; 75 stays "already running"). `ContextRefused` carries the code:
230
+ (`EXIT_CONTEXT_REFUSED`, EX_CONFIG; 75 stays "already running"); a copy its supervisor started waits the lock's
231
+ back-off (300 s in all) before that exit, so `KeepAlive` does not restart it every 10 s (FAA-279). `ContextRefused`
232
+ carries the code:
222
233
 
223
234
  | Code | When |
224
235
  |---|---|
@@ -125,6 +125,7 @@ action it also carries the operator's decision, [Actions](#actions)), act on eve
125
125
  (`jobs.quotaExhausted(id, message, {retryable, resetsAt})`, `jobCallerAction`, `intentIdempotencyKey`). The guidance
126
126
  for an agent's own design is in `creating-fabric-agents/references/resilience-and-autonomy.md`.
127
127
 
128
+ <a id="a-nodes-call-queue"></a>
128
129
  **A node's call queue (DEC-0104).** A provider that queues refuses a start past its queue with `queue-full`: an `isError`
129
130
  result with one text item whose JSON is `{error: {code: "queue-full", message, data: {retryable, retryAfterSeconds}}}`,
130
131
  never `structuredContent`; the caller starts again after `retryAfterSeconds` under the same key. Read that refusal
@@ -132,9 +133,31 @@ from the start's own `isError` result, before any answer reaches `job_caller_act
132
133
  job, and `queue-full` never ends one, so they answer a job it ended `invalid` (`QUEUE_FULL` names the code, and the
133
134
  kit's job store refuses to end a job with it). The kit has no reader for the refusal yet (FAA-103). Through Fabric's hub
134
135
  a caller today sees `product-error` instead, kept under the key for 24 hours, until the hub relays `queue-full`
135
- (Fabric CO-376). The kit does not queue: it declares no load extension (`extensions/load/0.1`) and serves no
136
- `fabric.job.list`, so its `unknown-job` and repeated keys keep the rules above. A node that will queue needs
137
- `jobCallerKey`, the contract's `agent-network.json` vectors and the load report first (FAA-103).
136
+ (Fabric CO-376). The kit's job store does not queue by itself: it declares no load extension (`extensions/load/0.1`)
137
+ and serves no `fabric.job.list`, so its `unknown-job` and repeated keys keep the rules above.
138
+
139
+ A node that queues takes the contract's reference functions from the kit instead of copying them (since 0.80.0,
140
+ FAA-289): `fabric_load.py` and `fabric-load.mjs`, exported by both facades, held to the contract's
141
+ `fixtures/vectors/agent-network.json` and its `job-queue`, `service-load` and `service-well-known-load` fixtures at the
142
+ pin (vendored as `test/fixtures/contract/load-vectors.json` by `make_load_vectors.mjs`):
143
+
144
+ | Python | Node | What |
145
+ |---|---|---|
146
+ | `job_caller_key(accepted, credential)` | `jobCallerKey` | the caller's opaque key: an accepted delegation's `principal`, `caller` and a partner organization ending the chain, else the credential; `None` / `undefined` for a refused or unreadable call |
147
+ | `next_queued_job(queue, running, limits)` | `nextQueuedJob` | the id to start next per (agentRef, context): nothing at `concurrency`, a caller at `perCaller` waits, the caller running fewest first, then the oldest arrival, ties by id |
148
+ | `admit_job(queue, running, job, limits, retry_after_seconds)` | `admitJob` | `{admit: True}` when the job would start at once or fewer than `queueMax` wait, else the `queue-full` error with `retryAfterSeconds` (1 to 3600, else 60) |
149
+ | `queue_status_message(position, length)` | `queueStatusMessage` | a waiting job's `statusMessage`, `queued <position> of <length>` |
150
+ | `read_queue_position(status_message)` | `readQueuePosition` | `{position, length}` from that message, or none |
151
+ | `job_queue_rules(value)` | `jobQueueRules` | FAC-SEM-131 on `{queue, running, limits, started}` |
152
+ | `load_report_rules(report)` | `loadReportRules` | FAC-SEM-132 on a `service-load/1` report: windows one `windowSeconds` apart, none after `generatedAt`, one row per context, p50 at most p95, a run time only where a job ended |
153
+ | `load_report_problems(report)` | `loadReportProblems` | the report against `service-load.schema.json` (no caller, no job id, counts and durations only) |
154
+ | `load_view(well_known)`, `load_extension_rules(well_known)` | `loadView`, `loadExtensionRules` | the declaration as a reader reads it, and FAC-SEM-133: an invalid one is treated as not declared and reported |
155
+ | `load_extension(path, window_seconds, limits)` | `loadExtension({path, windowSeconds, limits})` | the `extensions` entry a service declares; raises `ServiceError` on one a reader would hide |
156
+
157
+ Queue times are UTC instants (`T`, `Z`, at most three fraction digits); report times are RFC 3339 read strictly. A
158
+ node keys every per-caller rule with `job_caller_key`, counts every started job that has not ended (`input_required`
159
+ and paused ones too) as running, and answers another caller's job id `unknown-job`. The text-only `queue-full` result
160
+ and `fabric.job.list` are still the node's own (FAA-103).
138
161
 
139
162
  <a id="actions"></a>
140
163
 
@@ -59,7 +59,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
59
59
  | L0 | `fabric_time` | strict RFC 3339, UTC days, formatting, day windows, the clock ([dates](#dates-and-times)) |
60
60
  | L0 | `fabric_identifiers` | identifier equality by normal form (DEC-0076) |
61
61
  | L0 | `fabric_secrets` | the contract's one credential detector (DEC-0044) |
62
- | L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py` 1.1.0 vendored from commit `f86f0622` with the readings its header names (`1.1.0+kit.3`), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) a caller may vouch with a pid it started, proved by `lsof -p` |
62
+ | L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py` 1.1.0 vendored from commit `f86f0622` with the readings its header names (`1.1.0+kit.4`), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) a caller may vouch with a pid it started, proved by `lsof -p` |
63
63
  | L0 | `fabric_tailnet` | who calls an agent behind `tailscale serve` and what that person may open (FAA-297): `Tailscale-User-Login` only on the agent's own serve listener from a loopback peer, groups from a generated `tailnet-perimeter/1` file, a personal SHA-256-stored key for MCP no wider than the groups; `authorize`, `load_perimeter`, `load_keys`, `hash_key`, refusals 401/403 with a code ([surfaces-and-auth.md](surfaces-and-auth.md#behind-tailscale-serve)) |
64
64
  | L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
65
65
  | L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
@@ -78,6 +78,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
78
78
  | L3 | `fabric_connections` | what an agent uses, `fabric-connections/0.1`: the reference reading and the declaration (DEC-0096, FAC-SEM-127) |
79
79
  | L3 | `fabric_a2a` | the reading side of an A2A Agent Card: `surfaces.a2a` as the schema reads it, and what a public card must not carry (DEC-0122) |
80
80
  | L3 | `fabric_page_links` | a service's pages as Fabric Dashboards' open_link: build and read it, a raw local address, FAC-SEM-143 on a link and on a URL-mode input request (DEC-0123); the Python twin reads URLs as WHATWG does by hand |
81
+ | L3 | `fabric_load` | the call queue and the load report of DEC-0104 (FAA-289): `job_caller_key`, `next_queued_job`, `admit_job`, `queue_status_message`, `read_queue_position`, FAC-SEM-131 to 133, the `service-load/1` and `extensions/load/0.1` shapes, `load_extension`; imports `fabric_connections` for ajv's `date-time` ([interop](interop.md#a-nodes-call-queue)) |
81
82
  | L3 | `fabric_lock` | the instance lock and the supervisor marker (LC-01, DEC-0057) |
82
83
  | L3 | `fabric_usage` | usage receipts, the ledger, the usage report, attribution, spending limits ([usage](usage.md)) |
83
84
  | L3 | `fabric_backups` | settings backups (DEC-0025) |
@@ -105,7 +106,8 @@ Beside the kit, not in `kit.json`: the probe (`check_service.py`, `check_service
105
106
  `check_service_channel_agent.py`, `check_service_channel_agent_schema.py` and the
106
107
  `service-channel-agent-extension.schema.json` it reads at run time), the sample
107
108
  (`sample_service.py`, `sample-remote-service.mjs`), `fabric_kit.py` itself and the dashboard tools. Recipes built on
108
- the kit live in `../recipes/` ([the draft-and-check step of a support agent](../recipes/support-agent/README.md)).
109
+ the kit live in `../recipes/` ([the draft-and-check step of a support agent](../recipes/support-agent/README.md), [a service that is only a
110
+ dashboard](../recipes/dashboard-only/README.md), [a key-delivery door](../recipes/key-delivery-door/README.md)).
109
111
 
110
112
  ## What 0.39 code keeps
111
113