@passioncode-ai/fabric-agent-adapter 0.61.0 → 0.62.0

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 (36) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/package.json +1 -1
  3. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  4. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +4 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +57 -0
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +11 -9
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +8 -1
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +19 -2
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +4 -2
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +4 -2
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +6 -4
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +16 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +18 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +33 -3
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +10 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -1
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +33 -3
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +14 -2
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +57 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +16 -1
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +55 -4
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +5 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +57 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +102 -12
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +42 -4
  34. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  35. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  36. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -18,6 +18,36 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
18
18
 
19
19
  sys.path.insert(0, str(Path(__file__).resolve().parent))
20
20
  import fabric_service as fs # noqa: E402 - beside this file
21
+ from fabric_http import redact_url # noqa: E402
22
+ from fabric_secrets import ascii_decoded, repository_name, secret_argument # noqa: E402
23
+
24
+ _LOCAL_PATH = re.compile(r"[/~.\\]|[A-Za-z]:[/\\]") # how a local path starts, never a user before an `@`
25
+ _SCP_HOST = re.compile(r"[^@/:\\]+:") # what follows the `@` of user[:password]@host:path
26
+ _SPECIAL = re.compile(r"(?:https?|wss?|ftp|file):", re.I) # the URL standard's special schemes, read with or without `//`
27
+ _URL = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://") # a scheme that starts the value; a later `://` is path text (unit reading 9)
28
+
29
+
30
+ def shown_repository(value: str) -> str:
31
+ """A repository or git origin as evidence: a URL through redact_url, which drops its user information as written
32
+ first (git's reading, `https://h\\u:pw@x/r`); a local path (one that starts with `/`, `~`, `.`,
33
+ `\\` or a drive) by its name; an scp form (user[:password]@host:path) from its host, its user and password dropped
34
+ whatever they hold, up to the last `@` that a host and `:` follow; anything else by its name. Then a query and a
35
+ fragment. A value with a piece shaped like a credential that is no name (fabric_secrets.repository_name) is not
36
+ printed (0.62.0 readings 2 to 11). Read first as redact_url reads it: C0 controls and space off its ends, every tab,
37
+ CR and LF out, so `https:/<tab>/u:pw@h` is the URL it is (unit reading 11)."""
38
+ value = re.sub(r"[\t\n\r]", "", value.strip("".join(map(chr, range(0x21)))))
39
+ if _URL.match(value) or _SPECIAL.match(value): # `https:user@host/x` is a URL too, as the URL standard reads it
40
+ shown = redact_url(value) # which drops the user information as written first (seam readings 10 and 11)
41
+ else:
42
+ cut = 0
43
+ if not _LOCAL_PATH.match(value):
44
+ ats = [i for i, c in enumerate(value) if c == "@"]
45
+ hosts = [at for at in ats if _SCP_HOST.match(value, at + 1)]
46
+ cut = (hosts or ats or [-1])[-1] + 1 # the last `@` a host and `:` follow, else the last `@` (unit reading 8)
47
+ shown = re.split(r"[?#]", value[cut:], maxsplit=1)[0]
48
+ pieces = [piece for written in (shown, ascii_decoded(shown)) for piece in re.split(r"[/\\:]", written)
49
+ if piece and not repository_name(piece)] # as written and decoded, `%73k-` being `sk-` (readings 11)
50
+ return "<a repository shaped like a credential>" if any(secret_argument(piece) for piece in pieces) else shown
21
51
  import fabric_actions as fa # noqa: E402 - beside this file (DEC-0121)
22
52
  import fabric_a2a as fa2 # noqa: E402 - beside this file (DEC-0122)
23
53
 
@@ -177,7 +207,7 @@ def board_responder_verdict(wk: Any, kit: Optional[str] = None) -> Tuple[str, st
177
207
  path = Path(kit) if kit else Path(__file__).resolve().parent / "fabric_board.py"
178
208
  code = ("import json, sys; sys.path.insert(0, sys.argv[1]); import %s as board; "
179
209
  "print(json.dumps(board.self_check()))" % path.stem)
180
- out = subprocess.run([sys.executable, "-E", "-s", "-c", code, str(path.resolve().parent)], capture_output=True,
210
+ out = subprocess.run([sys.executable, "-E", "-s", "-B", "-c", code, str(path.resolve().parent)], capture_output=True,
181
211
  text=True, timeout=60, cwd=str(path.resolve().parent))
182
212
  problems = json.loads(out.stdout.strip().splitlines()[-1]) if out.returncode == 0 and out.stdout.strip() else ["the kit did not run: %s" % out.stderr.strip()[-200:]]
183
213
  except Exception as exc: # noqa: BLE001 - a kit that cannot run its conversation fails the rule
@@ -658,5 +688,5 @@ def service_checkout(descriptor: Any, repo: Optional[str]) -> Tuple[Optional[Pat
658
688
  continue
659
689
  seen.add(top)
660
690
  if same_repository(_git_out(Path(top), "remote", "get-url", "origin") or "", source):
661
- return Path(top), "the checkout of %s that holds %s" % (source, place)
662
- return None, "no checkout of %s holds the descriptor's commands, manifest or launchd job; pass --repo" % source
691
+ return Path(top), "the checkout of %s that holds %s" % (shown_repository(source), place)
692
+ return None, "no checkout of %s holds the descriptor's commands, manifest or launchd job; pass --repo" % shown_repository(source)
@@ -7,7 +7,7 @@ import { PROTOCOL, ServiceError, nowIso } from './fabric-base.mjs';
7
7
  import { tokenMatches, windowsTokenProblem } from './fabric-platform.mjs';
8
8
  import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
9
9
  import { isPlainObject } from './fabric-usage.mjs';
10
- import { validateDescriptor, writeDescriptor } from './fabric-descriptor.mjs';
10
+ import { descriptorPath, namesRepository, sourceRefusal, validateDescriptor, writeDescriptor } from './fabric-descriptor.mjs';
11
11
 
12
12
  export const SESSION_COOKIE = 'fabric_session';
13
13
 
@@ -275,8 +275,17 @@ export function registerRemote({ id, instance = 'default', name, summary, origin
275
275
  installedAt: nowIso(), installedBy, ...(runner ? { runner: { ...runner } } : {}), ...(peer ? { peer: { ...peer } } : {}),
276
276
  ...(source != null ? { source: isPlainObject(source) ? { ...source } : source } : {}),
277
277
  };
278
+ if (source == null) { // a rotation that names no source keeps the installed one (0.62.0 seam reading 1)
279
+ let installed = null;
280
+ try { installed = JSON.parse(fs.readFileSync(descriptorPath(id, instance, dir), 'utf8'))?.source; } catch { /* none installed */ }
281
+ // Only one the kit accepts: a hand-edited source never blocks a rotation; it is dropped with the warning.
282
+ const base = new Set(validateDescriptor(d));
283
+ if (namesRepository({ source: installed }) && validateDescriptor({ ...d, source: installed }).every((p) => base.has(p))) d.source = { ...installed };
284
+ }
278
285
  const problems = validateDescriptor(d);
279
286
  if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
287
+ const refusal = sourceRefusal(d, dir); // before the token is written: a refused install leaves nothing (FAA-198)
288
+ if (refusal) throw new ServiceError(refusal);
280
289
  ensurePrivateDir(path.dirname(tokenFile));
281
290
  atomicWrite(tokenFile, String(token).trim(), 0o600);
282
291
  return writeDescriptor(d, dir);
@@ -432,7 +432,9 @@ export class JwksCache {
432
432
  if (typeof source !== 'string' || !source) throw new Error('the key set needs a source');
433
433
  if (/^https?:\/\//.test(source)) {
434
434
  const url = new URL(source);
435
- if (url.username || url.password) throw new Error('a key set URL carries no credentials');
435
+ const text = source.replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/[\t\n\r]/g, '');
436
+ // the authority as written too, as Python's netloc reads it (`https://h\\u:p@x/j`, `https://@x/j`: seam reading 13)
437
+ if (url.username || url.password || /^[a-z][a-z0-9+.-]*:[/\\]*[^/?#]*@/i.test(text)) throw new Error('a key set URL carries no credentials');
436
438
  if (url.protocol === 'http:' && !loopback(url.hostname)) throw new Error('a key set is read over https, or over http on loopback only');
437
439
  } else if (source.includes('://')) {
438
440
  throw new Error('a key set is an https URL, a loopback http URL or a file path');
@@ -2,7 +2,7 @@
2
2
  // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_descriptor.py. Node 20+, no dependency.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { credentialName, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
5
+ import { credentialName, repositoryCredential, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
6
6
  import { ID, INSTANCE, LOCAL_PATH, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
7
7
  import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
8
8
  import { protocolsFindings } from './fabric-protocols.mjs';
@@ -182,7 +182,7 @@ export function descriptorMemberProblems(d) {
182
182
  for (const k of Object.keys(runner)) if (!['kind', 'credentialKind'].includes(k)) problems.push(`runner has no member ${k}`);
183
183
  if (!CREDENTIAL_KINDS.includes(runner.credentialKind)) problems.push('runner.credentialKind is subscription, key, platform-metered or none');
184
184
  if ('kind' in runner && !(typeof runner.kind === 'string' && RUNNER_KIND_NAME.test(runner.kind))) problems.push("runner.kind is a runner kind's name");
185
- if (runner.credentialKind === 'subscription' && !Object.hasOwn(SUBSCRIPTION_RUNNERS, runner.kind)) problems.push("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex");
185
+ if (runner.credentialKind === 'subscription' && !(typeof runner.kind === 'string' && Object.hasOwn(SUBSCRIPTION_RUNNERS, runner.kind))) problems.push("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex");
186
186
  for (const [k, v] of Object.entries(runner)) places.push([`/runner/${pointer(k)}`, v]);
187
187
  }
188
188
  if ('source' in d) {
@@ -194,7 +194,8 @@ export function descriptorMemberProblems(d) {
194
194
  if (!(typeof repository === 'string' && SOURCE_URI.test(repository.replace(IP_LITERAL, '$1h')))) problems.push('source.repository is a URI (RFC 3986), such as https://github.com/owner/name');
195
195
  else if (URI_PASSWORD.test(repository)) problems.push('source.repository carries no password: it names the repository, never a credential'); // before the host
196
196
  else if (!authorityOk(repository)) problems.push('source.repository names a host, and a port of digits up to 65535 when it has one');
197
- places.push(['/source/repository', repository]);
197
+ else if (repository.includes('@') && !HIERARCHICAL.test(repository)) problems.push('source.repository with a user is a URL with an authority (https://user@host/path), never an scp form'); // readings 7
198
+ else if (repositoryCredential(repository)) problems.push('FAC-SEM-086: /source/repository holds a value shaped like a credential; it names a repository'); // its name is no key (seam 6)
198
199
  }
199
200
  }
200
201
  if ('peer' in d) {
@@ -290,10 +291,39 @@ export function portOf(origin) {
290
291
  return Number(m[1]);
291
292
  }
292
293
 
294
+ export const NO_SOURCE_REPOSITORY = 'names no source.repository, the repository its code is built from '
295
+ + '(https://github.com/owner/name): it links the agent to its project (FAA-198)';
296
+ const PASS_SOURCE = 'Name it in this call: source: { repository } in the descriptor writeDescriptor writes, or in '
297
+ + 'registerRemote\'s options; one written after the call returns is too late for a new install.';
298
+
299
+ const HIERARCHICAL = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
300
+
301
+ // FAA-198: why a new install of this (valid) descriptor is refused, or null: it names no source.repository and no
302
+ // descriptor of its id.instance is installed yet. An installer checks it before it writes anything else.
303
+ export const namesRepository = (d) => d?.source !== null && typeof d?.source === 'object' && !Array.isArray(d.source)
304
+ && typeof d.source.repository === 'string' && d.source.repository !== '';
305
+
306
+ // An installed descriptor is a regular file, whatever it holds; a folder, a broken link or a path that cannot be
307
+ // examined is none.
308
+ export function sourceRefusal(d, dir = servicesDir()) {
309
+ if (namesRepository(d)) return null;
310
+ const instance = d?.instance ?? 'default';
311
+ // An id or instance that is no string names no installed file, in both kits (seam reading 3).
312
+ try { if (typeof d?.id === 'string' && typeof instance === 'string' && fs.statSync(descriptorPath(d.id, instance, dir)).isFile()) return null; } catch { /* not installed */ }
313
+ return `Descriptor ${d?.id}.${instance} ${NO_SOURCE_REPOSITORY}. ${PASS_SOURCE}`;
314
+ }
315
+
316
+ // FAA-198: a new descriptor names its source.repository; rewriting one already installed without it only warns
317
+ // (process.emitWarning, code FABRIC_NO_SOURCE_REPOSITORY), so an installed agent's update keeps working.
293
318
  export function writeDescriptor(d, dir = servicesDir()) {
294
319
  const problems = validateDescriptor(d);
295
320
  if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
296
321
  const me = `${d.id}.${d.instance}`;
322
+ const refusal = sourceRefusal(d, dir);
323
+ if (refusal) throw new ServiceError(refusal);
324
+ if (!namesRepository(d)) {
325
+ process.emitWarning(`Descriptor ${me} ${NO_SOURCE_REPOSITORY}; a new install is refused without it.`, { code: 'FABRIC_NO_SOURCE_REPOSITORY' });
326
+ }
297
327
  // DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
298
328
  const port = placementOf(d) === 'remote' || d.origin === undefined ? null : portOf(d.origin);
299
329
  for (const [file, other] of readDescriptors(dir)) {
@@ -52,8 +52,17 @@ export class Response {
52
52
 
53
53
  // The URL as it may appear in an error or a log line: scheme, host, port and path; no userinfo, query or fragment.
54
54
  export function redactUrl(url) {
55
+ let text = String(url).replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/[\t\n\r]/g, '');
56
+ // The user information as written goes first, as the Python twin drops it: `https://h\\u:pw@x/r` would print `pw` in
57
+ // its path, a backslash being a slash to WHATWG (0.62.0 seam reading 11).
58
+ const written = /^([A-Za-z][A-Za-z0-9+.-]*:[/\\]*)([^/?#]*)/.exec(text); // after any run of slashes (seam reading 13)
59
+ if (written && written[2].includes('@')) text = written[1] + written[2].slice(written[2].lastIndexOf('@') + 1) + text.slice(written[0].length);
55
60
  let parts;
56
- try { parts = new URL(String(url)); } catch { return '<unparseable url>'; }
61
+ try { parts = new URL(text); } catch {
62
+ // a scheme other than a special one keeps its name, as the Python twin prints it whatever follows (unit reading 14)
63
+ const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(text)?.[1].toLowerCase();
64
+ return scheme && !['http', 'https', 'ws', 'wss', 'ftp'].includes(scheme) ? `${scheme}:<redacted>` : '<unparseable url>';
65
+ }
57
66
  // Only http and https are sent; any other scheme keeps no more than its name (0.56.0 product r10).
58
67
  if (parts.protocol !== 'http:' && parts.protocol !== 'https:') return `${parts.protocol}<redacted>`;
59
68
  return `${parts.protocol}//${parts.host}${parts.pathname}`;
@@ -74,7 +83,10 @@ export function checkUrl(url, { insecure = false } = {}) {
74
83
  if (!['http:', 'https:'].includes(parts.protocol) || !parts.hostname) {
75
84
  throw new HttpError('only http and https URLs with a host are sent', { url: redactUrl(url), kind: 'refused' });
76
85
  }
77
- if (parts.username || parts.password || /^[a-z]+:\/*[^/?#]*@/i.test(String(url).trim())) {
86
+ // the user information as written, read on the text `new URL` reads (C0 and space off its ends, tab, CR and LF out),
87
+ // so `ht\ttps://h\\u:pw@x/r` is refused as the Python twin refuses it (0.62.0 unit reading 12)
88
+ const text = String(url).replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/[\t\n\r]/g, '');
89
+ if (parts.username || parts.password || /^[a-z][a-z0-9+.-]*:[/\\]*[^/?#]*@/i.test(text)) {
78
90
  throw new HttpError('credentials in a URL are refused: send them in a header', { url: redactUrl(url), kind: 'refused' });
79
91
  }
80
92
  if (parts.protocol === 'http:' && !insecure && !isLoopback(parts.hostname)) {
@@ -84,3 +84,60 @@ export function secretUrl(value) {
84
84
  }
85
85
  return false;
86
86
  }
87
+
88
+ const REPOSITORY_URI = /^([A-Za-z][A-Za-z0-9+.-]*:(?:\/\/[^/?#]*)?)([^?#]*)([\s\S]*)$/;
89
+ const REPOSITORY_NAME = /^(?:[a-z0-9._-]+|[A-Za-z._-]+)$/; // lower case, or letters with no digit (readings 8)
90
+ const REPOSITORY_AUTHORITY = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
91
+ const KEY_AT_A_WORD = new RegExp(`(?:^|(?<=[._-]))(?:${SECRET_VALUE.source})`, 'i');
92
+ const JWT_AT_A_WORD = new RegExp(`(?:^|(?<=[._-]))${JWT_SHAPE.source}`);
93
+ const SPECIAL_QUERY = /^(?:https?|wss?|ftp|file):/i; // whose query also encodes `'`, as the URL standard does
94
+
95
+ /** Whether a path segment reads as a repository's name: lower case, or letters with no digit, with no provider key's
96
+ * or JWT's shape at its start or after a `.`, `_` or `-` (0.62.0 readings 9 and 10), so a letters-only key is a key. */
97
+ export const repositoryName = (piece) => REPOSITORY_NAME.test(piece) && !KEY_AT_A_WORD.test(piece) && !JWT_AT_A_WORD.test(piece);
98
+
99
+ /** Every `%XX` escape of an ASCII character decoded, the rest as written (fabric_secrets.ascii_decoded, readings 11). */
100
+ export const asciiDecoded = (text) => text.replace(/%([0-7][0-9A-Fa-f])/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
101
+
102
+ /** Whether a repository URI's user information holds a password: every slash and backslash after the colon skipped,
103
+ * then up to the next `/`, `?` or `#` (fabric_secrets.repository_password, 0.62.0 readings 10 to 14). */
104
+ export function repositoryPassword(value) {
105
+ const text = value.replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/[\t\n\r]/g, '');
106
+ const colon = text.indexOf(':');
107
+ const rest = colon < 0 ? '' : text.slice(colon + 1); // as Python's partition leaves it (unit reading 12)
108
+ const authorities = colon < 0 ? [] : [/^[^/?#]*/.exec(rest.replace(/^[/\\]+/, ''))[0]];
109
+ const users = authorities.map((authority) => authority.slice(0, Math.max(authority.lastIndexOf('@'), 0)));
110
+ return [...users, ...users.map(asciiDecoded)].some((user) => user.includes(':') && user.slice(user.indexOf(':') + 1) !== '');
111
+ }
112
+
113
+ // A repository's parameters, read as fabric_secrets.secret_url reads them, by hand: `new URL`
114
+ // refuses a host it cannot parse and a special scheme written without `//` (`https:host/r?access_token=...`), where
115
+ // the Python twin still reads them (0.62.0 readings 8 and 9, FAA-200 (17)).
116
+ function repositoryParts(head, tail) {
117
+ const text = tail.replace(/[\x00-\x20]+$/, '').replace(/[\t\n\r]/g, '');
118
+ const hash = text.indexOf('#');
119
+ const before = hash < 0 ? text : text.slice(0, hash);
120
+ const query = before.startsWith('?') ? before.slice(1) : '';
121
+ const fragment = hash < 0 ? '' : text.slice(hash + 1);
122
+ try { // percent-encode them as `new URL` does, on a host it always reads
123
+ const url = new URL(`${SPECIAL_QUERY.test(head) ? 'https://h.invalid/' : 'x-repository://h/'}?${query}#${fragment}`);
124
+ return [url.search.slice(1), url.hash.slice(1)];
125
+ } catch { return [query, fragment]; }
126
+ }
127
+
128
+ // FAA-198 (0.62.0 readings 6 and 7): whether a repository URI carries a credential: in its scheme and authority (a
129
+ // user), in its query or fragment as secretUrl reads them, or as a path segment shaped like a key. A path segment in
130
+ // lower case, or of letters with no digit (task-pipeline-dashboard, Task-Pipeline-Dashboard), is the repository's
131
+ // name, never a key, though it reads as an `sk-` shape, unless a key's shape starts it or follows a `.`, `_` or `-` in
132
+ // it (repositoryName); a segment is read as written and asciiDecoded. A
133
+ // user outside a URL's authority (an scp form, `https:user@host/path`) counts as one: a user is an authority's.
134
+ export function repositoryCredential(value) {
135
+ if (value.includes('@') && !REPOSITORY_AUTHORITY.test(value)) return true;
136
+ const match = REPOSITORY_URI.exec(value);
137
+ if (!match) return secretArgument(value);
138
+ const [, head, path, tail] = match;
139
+ const odd = [path, asciiDecoded(path)].flatMap((written) => written.split(/[/\\:]/)).filter((piece) => piece && !repositoryName(piece));
140
+ if (repositoryPassword(value) || secretArgument(head + tail) || secretArgument(asciiDecoded(head)) || odd.some((piece) => secretArgument(piece))) return true; // the authority decoded too (unit reading 12)
141
+ return repositoryParts(head, tail).some((part) => [...rawValues(part)].some(([name, values]) =>
142
+ values.some((raw) => (raw !== '' && credentialName(name)) || secretValue(raw))));
143
+ }
@@ -118,6 +118,9 @@ export {
118
118
  placementOf,
119
119
  remoteOriginProblems,
120
120
  portOf,
121
+ NO_SOURCE_REPOSITORY,
122
+ namesRepository,
123
+ sourceRefusal,
121
124
  writeDescriptor,
122
125
  removeDescriptor,
123
126
  dashboardExtensionProblems,
@@ -36,6 +36,9 @@ from fabric_paths import (
36
36
  services_dir,
37
37
  )
38
38
  from fabric_descriptor import (
39
+ descriptor_path,
40
+ names_repository,
41
+ source_refusal,
39
42
  validate_descriptor,
40
43
  write_descriptor,
41
44
  )
@@ -393,6 +396,15 @@ def register_remote(*, service_id: str, name: str, origin: str, token: str, inst
393
396
  descriptor["runner"] = dict(runner)
394
397
  if peer is not None:
395
398
  descriptor["peer"] = dict(peer)
399
+ if source is None: # a rotation that names no source keeps the installed one (0.62.0 seam reading 1)
400
+ try:
401
+ installed = json.loads(descriptor_path(service_id, instance, root).read_text(encoding="utf-8")).get("source")
402
+ except (OSError, ValueError, AttributeError):
403
+ installed = None
404
+ # Only one the kit accepts: a hand-edited source never blocks a rotation; it is dropped with the warning.
405
+ base = set(validate_descriptor(descriptor))
406
+ source = installed if names_repository({"source": installed}) and set(
407
+ validate_descriptor(dict(descriptor, source=installed))) <= base else None
396
408
  if source is not None:
397
409
  descriptor["source"] = dict(source) if isinstance(source, dict) else source
398
410
  if doctor:
@@ -400,9 +412,12 @@ def register_remote(*, service_id: str, name: str, origin: str, token: str, inst
400
412
  problems = validate_descriptor(descriptor)
401
413
  if problems:
402
414
  raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
415
+ refusal = source_refusal(descriptor, root) # before the token is written: a refused install leaves nothing (FAA-198)
416
+ if refusal:
417
+ raise ServiceError(refusal)
403
418
  ensure_private_dir(token_file.parent)
404
419
  atomic_write(token_file, token.strip().encode(), 0o600)
405
- return write_descriptor(descriptor, root)
420
+ return write_descriptor(descriptor, root, _stacklevel=3) # the warning names the installer's call
406
421
 
407
422
 
408
423
  def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Optional[str]:
@@ -9,6 +9,7 @@ import os
9
9
  import re
10
10
  import socketserver
11
11
  import subprocess
12
+ import warnings
12
13
  from pathlib import Path
13
14
  from typing import Any, Dict, List, Optional, Sequence, Tuple
14
15
 
@@ -294,7 +295,9 @@ def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
294
295
  problems.append("runner.credentialKind is subscription, key, platform-metered or none")
295
296
  if "kind" in runner and not (isinstance(runner["kind"], str) and _RUNNER_KIND_NAME.match(runner["kind"])):
296
297
  problems.append("runner.kind is a runner kind's name")
297
- if runner.get("credentialKind") == "subscription" and runner.get("kind") not in SUBSCRIPTION_RUNNERS:
298
+ # a kind that is no string is no runner's (a list would raise as a key: 0.62.0 unit reading 17)
299
+ if runner.get("credentialKind") == "subscription" and not (isinstance(runner.get("kind"), str)
300
+ and runner["kind"] in SUBSCRIPTION_RUNNERS):
298
301
  problems.append("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex")
299
302
  places += [("/runner/%s" % _pointer(k), v) for k, v in runner.items()]
300
303
  if "source" in descriptor:
@@ -311,7 +314,10 @@ def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
311
314
  problems.append("source.repository carries no password: it names the repository, never a credential")
312
315
  elif not _authority_ok(repository):
313
316
  problems.append("source.repository names a host, and a port of digits up to 65535 when it has one")
314
- places.append(("/source/repository", repository))
317
+ elif "@" in repository and not _HIERARCHICAL.match(repository): # 0.62.0 readings 7: never an scp form's user
318
+ problems.append("source.repository with a user is a URL with an authority (https://user@host/path), never an scp form")
319
+ elif fabric_secrets.repository_credential(repository): # its name is no key (0.62.0 seam reading 6)
320
+ problems.append("FAC-SEM-086: /source/repository holds a value shaped like a credential; it names a repository")
315
321
  if "peer" in descriptor:
316
322
  peer = descriptor.get("peer")
317
323
  if not isinstance(peer, dict):
@@ -434,13 +440,58 @@ def port_of(origin: str) -> int:
434
440
  return int(match.group(1))
435
441
 
436
442
 
437
- def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None) -> Path:
438
- """Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim)."""
443
+ NO_SOURCE_REPOSITORY = ("names no source.repository, the repository its code is built from "
444
+ "(https://github.com/owner/name): it links the agent to its project (FAA-198)")
445
+ PASS_SOURCE = ("Name it in this call: \"source\": {\"repository\": ...} in the descriptor write_descriptor writes, or "
446
+ "source= to register_remote; one written after the call returns is too late for a new install.")
447
+
448
+
449
+ class NoSourceRepositoryWarning(UserWarning):
450
+ """FAA-198: an installed descriptor rewritten without source.repository (Node: process.emitWarning, code
451
+ FABRIC_NO_SOURCE_REPOSITORY). A new install without it is refused instead."""
452
+
453
+
454
+ def names_repository(descriptor: Any) -> bool:
455
+ source = descriptor.get("source") if isinstance(descriptor, dict) else None
456
+ return isinstance(source, dict) and isinstance(source.get("repository"), str) and bool(source["repository"])
457
+
458
+
459
+ _HIERARCHICAL = re.compile(r"\A[A-Za-z][A-Za-z0-9+.-]*://")
460
+
461
+
462
+ def source_refusal(descriptor: Dict[str, Any], directory: Optional[Path] = None) -> Optional[str]:
463
+ """FAA-198: why a new install of this descriptor is refused, or None: it names no source.repository and no
464
+ descriptor of its id.instance is installed yet (a regular file, whatever it holds; a folder, a broken link or a path
465
+ that cannot be examined is none). An installer may check it before it validates or writes anything else."""
466
+ if names_repository(descriptor):
467
+ return None
468
+ d = descriptor if isinstance(descriptor, dict) else {}
469
+ instance = d.get("instance") if d.get("instance") is not None else "default" # as Node's `??` reads it
470
+ me = "%s.%s" % (d.get("id"), instance)
471
+ try: # an id or instance that is no string names no installed file, in both kits (seam reading 3)
472
+ if isinstance(d.get("id"), str) and isinstance(instance, str) and descriptor_path(d["id"], instance, directory).is_file():
473
+ return None
474
+ except (OSError, ValueError): # an unsearchable folder reads as not installed, as Node's statSync does
475
+ pass
476
+ return "Descriptor %s %s. %s" % (me, NO_SOURCE_REPOSITORY, PASS_SOURCE)
477
+
478
+
479
+ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None, *, _stacklevel: int = 2) -> Path:
480
+ """Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim).
481
+
482
+ FAA-198: a new descriptor names its ``source.repository``; rewriting one already installed without it only
483
+ warns (``NoSourceRepositoryWarning``), so an installed agent's update keeps working."""
439
484
  problems = validate_descriptor(descriptor)
440
485
  if problems:
441
486
  raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
442
487
  root = directory or services_dir()
443
488
  me = "%s.%s" % (descriptor["id"], descriptor["instance"])
489
+ refusal = source_refusal(descriptor, root)
490
+ if refusal:
491
+ raise ServiceError(refusal)
492
+ if not names_repository(descriptor):
493
+ warnings.warn("Descriptor %s %s; a new install is refused without it." % (me, NO_SOURCE_REPOSITORY),
494
+ NoSourceRepositoryWarning, stacklevel=_stacklevel)
444
495
  # DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
445
496
  port = None if placement_of(descriptor) == "remote" or "origin" not in descriptor else port_of(descriptor["origin"])
446
497
  for path, other in read_descriptors(root):
@@ -157,6 +157,11 @@ def redact_url(url: str) -> str:
157
157
  # then remove every tab, CR and LF, so ` https://u:pw@h` and `ht\ttps:u:pw@h` lose their password too (0.56.0
158
158
  # seam r10; Python 3.9's urlsplit keeps a leading space in the scheme).
159
159
  url = re.sub(r"[\t\n\r]", "", str(url).strip("".join(map(chr, range(0x21)))))
160
+ # The user information as written goes first, as git and urllib read it, where WHATWG reads a backslash as a slash:
161
+ # `https://h\\u:pw@x/r` would print `pw` in its path (0.62.0 seam reading 11). The Node twin does the same.
162
+ written = re.match(r"([A-Za-z][A-Za-z0-9+.-]*:[/\\]*)([^/?#]*)", url) # after any run of slashes (seam reading 13)
163
+ if written and "@" in written.group(2):
164
+ url = written.group(1) + written.group(2).rpartition("@")[2] + url[written.end():]
160
165
  scheme, colon, rest = url.partition(":")
161
166
  if colon and scheme.lower() in ("http", "https", "ws", "wss", "ftp"):
162
167
  # A special scheme as WHATWG reads it: the authority starts after every slash or backslash that follows the
@@ -185,3 +185,60 @@ def secret_url(value: str) -> bool:
185
185
  if any((raw != "" and credential_name(name)) or secret_value(raw) for raw in raws):
186
186
  return True
187
187
  return False
188
+
189
+
190
+ _REPOSITORY_URI = re.compile(r"\A([A-Za-z][A-Za-z0-9+.-]*:(?://[^/?#]*)?)([^?#]*)(.*)\Z", re.S)
191
+ _REPOSITORY_NAME = re.compile(r"(?:[a-z0-9._-]+|[A-Za-z._-]+)\Z") # lower case, or letters with no digit (readings 8)
192
+ _REPOSITORY_AUTHORITY = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://")
193
+ _KEY_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))(?:%s)" % SECRET_VALUE.pattern, re.IGNORECASE | re.ASCII)
194
+ _JWT_AT_A_WORD = re.compile(r"(?:\A|(?<=[._-]))%s" % JWT_SHAPE.pattern)
195
+
196
+
197
+ def repository_name(piece: str) -> bool:
198
+ """Whether a path segment reads as a repository's name: lower case, or letters with no digit, with no provider
199
+ key's or JWT's shape (`sk-`, `ghp_`, `xoxb-`, `AKIA`, `eyJ`) at its start or after a `.`, `_` or `-` (0.62.0
200
+ readings 9 and 10), so a letters-only key is still a key while `task-pipeline-dashboard`, which holds an `sk-` shape
201
+ inside a word, is a name."""
202
+ return bool(_REPOSITORY_NAME.match(piece)) and not _KEY_AT_A_WORD.search(piece) and not _JWT_AT_A_WORD.search(piece)
203
+
204
+
205
+ def ascii_decoded(text: str) -> str:
206
+ """Every `%XX` escape of an ASCII character decoded, the rest left as written: how a repository's path and user
207
+ information are read as well as written, since git and hosts decode them (`%73k-` is `sk-`, `u%3Apw` is `u:pw`;
208
+ 0.62.0 readings 11). The Node twin is asciiDecoded."""
209
+ return re.sub(r"%([0-7][0-9A-Fa-f])", lambda escape: chr(int(escape.group(1), 16)), text)
210
+
211
+
212
+ def repository_password(value: str) -> bool:
213
+ """Whether a repository URI's user information holds a password, in any scheme, `file` too: every slash and
214
+ backslash after the colon skipped, then up to the next `/`, `?` or `#`, a backslash kept in the user. That one
215
+ reading holds git's (`https://h\\u:pw@x/r`), curl's (`https:///h\\u:pw@x`) and the URL standard's
216
+ (`https:///u:pw@h`, which stops at a backslash, a prefix of it) (0.62.0 readings 10 to 14). The user information
217
+ ends at the last `@`; a password follows its first `:`, as written or ascii_decoded. The Node twin is
218
+ repositoryPassword."""
219
+ value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
220
+ scheme, colon, rest = value.partition(":")
221
+ authorities = [re.match(r"[^/?#]*", rest.lstrip("/\\")).group(0)] if colon else []
222
+ users = [authority.rpartition("@")[0] for authority in authorities]
223
+ return any(user.partition(":")[2] for user in users + [ascii_decoded(user) for user in users])
224
+
225
+
226
+ def repository_credential(value: str) -> bool:
227
+ """FAA-198 (0.62.0 readings 6 and 7): whether a repository URI carries a credential: in its scheme and authority (a
228
+ user), in its query or fragment as secret_url reads them, or as a path segment shaped like a key. A path segment in
229
+ lower case, or of letters with no digit (task-pipeline-dashboard, Task-Pipeline-Dashboard), is the repository's
230
+ name, never a key, though it reads as an `sk-` shape, unless a key's shape starts it or follows a `.`, `_` or `-` in
231
+ it (repository_name); a segment is read as written and ascii_decoded. A
232
+ user outside a URL's authority (an scp form, `https:user@host/path`) counts as one: a user is an authority's."""
233
+ if "@" in value and not _REPOSITORY_AUTHORITY.match(value):
234
+ return True
235
+ match = _REPOSITORY_URI.match(value)
236
+ if not match:
237
+ return secret_argument(value)
238
+ head, path, tail = match.groups()
239
+ if repository_password(value):
240
+ return True
241
+ odd = [piece for written in (path, ascii_decoded(path)) for piece in re.split(r"[/\\:]", written)
242
+ if piece and not repository_name(piece)]
243
+ # the scheme and authority decoded too: git sends `https://%67hp_...@h` with its user decoded (unit reading 12)
244
+ return secret_url(head + tail) or secret_argument(ascii_decoded(head)) or any(secret_argument(piece) for piece in odd)
@@ -231,6 +231,10 @@ from fabric_descriptor import ( # noqa: F401
231
231
  placement_of,
232
232
  remote_origin_problems,
233
233
  port_of,
234
+ NO_SOURCE_REPOSITORY,
235
+ NoSourceRepositoryWarning,
236
+ names_repository,
237
+ source_refusal,
234
238
  write_descriptor,
235
239
  remove_descriptor,
236
240
  build_info,