@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.
- package/CHANGELOG.md +104 -0
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +4 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +11 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +19 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +6 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +18 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +16 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +55 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +102 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +42 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py
CHANGED
|
@@ -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)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs
CHANGED
|
@@ -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);
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs
CHANGED
|
@@ -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
|
-
|
|
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');
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs
CHANGED
|
@@ -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
|
-
|
|
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)) {
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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)) {
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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]:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py
CHANGED
|
@@ -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)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -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,
|