@intentius/chant-lexicon-fountain 0.52.2 → 0.53.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/dist/deep-observe-hooks.d.ts +78 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +84 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/deep-observe-hooks.ts +237 -0
- package/src/deep-observe.test.ts +503 -0
- package/src/deep-observe.ts +293 -0
- package/src/index.ts +10 -0
- package/src/plugin.ts +12 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fountain deep-observation noise rules (#1217).
|
|
3
|
+
*
|
|
4
|
+
* Plain data, imported statically by `plugin.ts`, because core applies the
|
|
5
|
+
* identical rules to the *declared* property tree — which no reader ever
|
|
6
|
+
* touches — before it diffs. Burying them inside the read would normalize the
|
|
7
|
+
* two sides differently and report everything as drift.
|
|
8
|
+
*
|
|
9
|
+
* ## Why a static table and not an ownership walk
|
|
10
|
+
*
|
|
11
|
+
* Kubernetes records `managedFields`, so the k8s row can subtract whatever a
|
|
12
|
+
* controller owns. A fountain REST payload never says who wrote a field, so
|
|
13
|
+
* this row follows the GCP precedent (`lexicons/gcp/src/deep-observe-hooks.ts`)
|
|
14
|
+
* — a hand-maintained table naming what the server populates. The cost is
|
|
15
|
+
* explicit: a server-set field nobody has listed reads as drift until it is
|
|
16
|
+
* listed. That is visible and fixable; an over-broad rule silently hides real
|
|
17
|
+
* drift, which is the worse of the two failures.
|
|
18
|
+
*
|
|
19
|
+
* Every value below is read off fountain's own Ecto schemas (`Environment`,
|
|
20
|
+
* `Vault`, `Agent`) and the JSON views that render them, not guessed from a
|
|
21
|
+
* sample payload.
|
|
22
|
+
*
|
|
23
|
+
* ## Secrets
|
|
24
|
+
*
|
|
25
|
+
* chant authors an environment's or vault's `secrets` as an ordered
|
|
26
|
+
* `{key, value}[]`, so the declared node holds real secret material. Core's
|
|
27
|
+
* key-name mask (`SENSITIVE_KEY_PATTERNS` matches `secrets`) collapses the
|
|
28
|
+
* whole node to `[REDACTED]` on both trees before any diff sees it, which is
|
|
29
|
+
* the correct outcome and also the limit of what this row can report: presence
|
|
30
|
+
* — an environment that declares no secrets and has some, or the reverse —
|
|
31
|
+
* never the key set, and never a value. Making the key set diffable needs
|
|
32
|
+
* fountain#148's reference model, where secrets stop being inline values.
|
|
33
|
+
*
|
|
34
|
+
* {@link fountainDeepNormalizationHooks.mask} adds one narrow rule on top:
|
|
35
|
+
* a string matching a known credential shape is collapsed wherever it appears,
|
|
36
|
+
* whatever the key is called. FTN001/FTN012 already refuse those at lint time;
|
|
37
|
+
* this is the backstop for a value that reached the live instance some other
|
|
38
|
+
* way, so a drift row can never print one.
|
|
39
|
+
*/
|
|
40
|
+
import type { DeepNormalizationHooks } from "@intentius/chant/deep-observation";
|
|
41
|
+
export declare const ENVIRONMENT_TYPE = "Fountain::V1::Environment";
|
|
42
|
+
export declare const VAULT_TYPE = "Fountain::V1::Vault";
|
|
43
|
+
export declare const AGENT_TYPE = "Fountain::V1::Agent";
|
|
44
|
+
/**
|
|
45
|
+
* Top-level payload fields fountain writes and a caller cannot: the primary
|
|
46
|
+
* key, the `timestamps()` pair, the owning user, the virtual `*_count` rollups
|
|
47
|
+
* the `*_with_counts` reads attach, the avatar's derived media type, and `acp`
|
|
48
|
+
* (computed per request from the runtime, never stored).
|
|
49
|
+
*
|
|
50
|
+
* Pruned on BOTH sides and regardless of what source declared, since a user who
|
|
51
|
+
* writes one is writing something the API overwrites anyway.
|
|
52
|
+
*
|
|
53
|
+
* Matched on the whole pattern rather than its last segment: `name` and `id`
|
|
54
|
+
* also occur *inside* `skills[]` and `repositories[]`, where they are authored
|
|
55
|
+
* configuration. `SERVER_FIELDS` is shared with the import/export path so the
|
|
56
|
+
* two cannot disagree about what a caller may author.
|
|
57
|
+
*/
|
|
58
|
+
export declare const FOUNTAIN_SERVER_FIELDS: ReadonlySet<string>;
|
|
59
|
+
/**
|
|
60
|
+
* Per-kind values fountain fills in when the request omits them, keyed by the
|
|
61
|
+
* index-erased pattern from the tree root.
|
|
62
|
+
*
|
|
63
|
+
* Noise only where **source never declared the property** — the
|
|
64
|
+
* `counterpart === "absent"` gate below. A default somebody wrote out
|
|
65
|
+
* explicitly is a fact worth diffing, and a later change away from it has to
|
|
66
|
+
* stay reportable.
|
|
67
|
+
*
|
|
68
|
+
* Straight off the Ecto `schema` blocks. `networking_type: "unrestricted"` is
|
|
69
|
+
* the one worth arguing about: subtracting it means an environment that never
|
|
70
|
+
* states its networking posture does not report one. That case is FTN010's, at
|
|
71
|
+
* lint time, where it is a build finding rather than drift; and the case this
|
|
72
|
+
* row exists for — a reviewed `limited` environment flipped to `unrestricted`
|
|
73
|
+
* in the UI — has a declared counterpart, so the gate keeps it and it reports
|
|
74
|
+
* as `changed`.
|
|
75
|
+
*/
|
|
76
|
+
export declare const FOUNTAIN_DEFAULTS: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
|
|
77
|
+
export declare const fountainDeepNormalizationHooks: DeepNormalizationHooks;
|
|
78
|
+
//# sourceMappingURL=deep-observe-hooks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deep-observe-hooks.d.ts","sourceRoot":"","sources":["../src/deep-observe-hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,OAAO,KAAK,EAGV,sBAAsB,EACvB,MAAM,mCAAmC,CAAC;AAG3C,eAAO,MAAM,gBAAgB,8BAA8B,CAAC;AAC5D,eAAO,MAAM,UAAU,wBAAwB,CAAC;AAChD,eAAO,MAAM,UAAU,wBAAwB,CAAC;AAEhD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,sBAAsB,EAAE,WAAW,CAAC,MAAM,CAKrD,CAAC;AAEH;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAiBzF,CAAC;AAoDF,eAAO,MAAM,8BAA8B,EAAE,sBA0E5C,CAAC"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fountain deep observation (#1217) — the fountain row of the deep-observe
|
|
3
|
+
* contract (#1014).
|
|
4
|
+
*
|
|
5
|
+
* `describeResources()` (./describe-resources.ts) answers whether a declared
|
|
6
|
+
* Environment/Vault/Agent exists and hands back its id and timestamps. That
|
|
7
|
+
* misses the drift the design was written for: an environment hand-edited in
|
|
8
|
+
* the fountain UI from `networking_type: limited` to `unrestricted`, an
|
|
9
|
+
* `allowed_vault_ids` allowlist widened, a skill repointed at an unpinned
|
|
10
|
+
* branch, a secret added to a reviewed sandbox. All of it lives one level
|
|
11
|
+
* down, in properties nobody was reading.
|
|
12
|
+
*
|
|
13
|
+
* ## The read is the thin path's read
|
|
14
|
+
*
|
|
15
|
+
* Transport, endpoint and auth are the applier's, unchanged
|
|
16
|
+
* (`FOUNTAIN_ENDPOINT` / `FOUNTAIN_TOKEN`), so plan reads the instance
|
|
17
|
+
* `fountainApply` writes. And the depth is free: fountain's list endpoints
|
|
18
|
+
* render the full record — `GET /api/environments` returns every configuration
|
|
19
|
+
* field the request schema accepts, not a summary — so there is no per-resource
|
|
20
|
+
* follow-up GET the way the AWS row needs Cloud Control on top of
|
|
21
|
+
* `describe-stack-resources`. One list per declared kind, cached, exactly as
|
|
22
|
+
* the thin path does it.
|
|
23
|
+
*
|
|
24
|
+
* ## The payload passes through
|
|
25
|
+
*
|
|
26
|
+
* fountain's JSON views name their fields the same way the request schema does
|
|
27
|
+
* (`networking_type`, `env_vars`, `skills`), so the live tree and the declared
|
|
28
|
+
* tree already speak one vocabulary — the AWS situation, not temporal's. The
|
|
29
|
+
* payload is therefore forwarded as-is and the noise rules
|
|
30
|
+
* (./deep-observe-hooks.ts) do the rest. A field fountain adds in a later
|
|
31
|
+
* release surfaces as `undeclared` until the table names it, which is the
|
|
32
|
+
* deliberate trade: visible and fixable beats silently dropped.
|
|
33
|
+
*
|
|
34
|
+
* One exception, and it is the reference edge. chant declares an agent's
|
|
35
|
+
* environment as a typed reference (`environment`), fountain stores the id it
|
|
36
|
+
* resolved to (`environment_id`). Passing the id through would report
|
|
37
|
+
* `<undeclared> -> <uuid>` on every clean read, so where source did not author
|
|
38
|
+
* `environment_id` itself the id is resolved back to the environment's name and
|
|
39
|
+
* emitted as `environment` — the same translation `exportResources()` does for
|
|
40
|
+
* the import path.
|
|
41
|
+
*
|
|
42
|
+
* ## Secrets: presence, never keys, never values
|
|
43
|
+
*
|
|
44
|
+
* Values are write-only upstream and are never read here at all. The secrets
|
|
45
|
+
* sub-resource is listed (keys and timestamps only) so that an environment or
|
|
46
|
+
* vault which declares no secrets and has some — somebody adding one to a
|
|
47
|
+
* locked-down sandbox — reports as drift. Core's key-name mask collapses the
|
|
48
|
+
* whole `secrets` node on both trees, so what a diff row can say is that
|
|
49
|
+
* secrets exist, not which. See the hooks module for why the key set itself is
|
|
50
|
+
* not expressible until fountain#148 lands.
|
|
51
|
+
*
|
|
52
|
+
* That listing is one extra request per observed Environment and Vault. A
|
|
53
|
+
* fountain tenant holds a handful of each, and the alternative — inferring
|
|
54
|
+
* presence from the newer payload's `secret_count` — would silently report
|
|
55
|
+
* "no secrets" against any instance predating that field.
|
|
56
|
+
*/
|
|
57
|
+
import type { DeepObservationResult } from "@intentius/chant/lexicon";
|
|
58
|
+
import { type FountainHttp } from "./op/activities/fountain-apply.js";
|
|
59
|
+
import { fountainDeepNormalizationHooks } from "./deep-observe-hooks.js";
|
|
60
|
+
export { fountainDeepNormalizationHooks };
|
|
61
|
+
export interface FountainDeepObserveOptions {
|
|
62
|
+
environment: string;
|
|
63
|
+
buildOutput?: string;
|
|
64
|
+
entityNames: string[];
|
|
65
|
+
entities: Map<string, {
|
|
66
|
+
entityType: string;
|
|
67
|
+
props: Record<string, unknown>;
|
|
68
|
+
}>;
|
|
69
|
+
stack?: string;
|
|
70
|
+
/** Restrict to resources carrying the `managed-by: chant` marker. */
|
|
71
|
+
owned?: boolean;
|
|
72
|
+
/** Endpoint override (tests). Defaults to resolveEndpoint(). */
|
|
73
|
+
endpoint?: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Read the live property tree for each declared fountain entity.
|
|
77
|
+
*
|
|
78
|
+
* `http` is injectable for tests; the default reuses the applier's fetch client
|
|
79
|
+
* (bearer token from FOUNTAIN_TOKEN). A missing token is the whole-lexicon
|
|
80
|
+
* failure the thin path already names — every declared entity NOT-OBSERVED with
|
|
81
|
+
* `no-credentials`, never an empty tree, which would read as "nothing drifted".
|
|
82
|
+
*/
|
|
83
|
+
export declare function observeResourcesDeepFountain(options: FountainDeepObserveOptions, injected?: FountainHttp): Promise<DeepObservationResult>;
|
|
84
|
+
//# sourceMappingURL=deep-observe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deep-observe.d.ts","sourceRoot":"","sources":["../src/deep-observe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AAEH,OAAO,KAAK,EACV,qBAAqB,EAGtB,MAAM,0BAA0B,CAAC;AAGlC,OAAO,EAML,KAAK,YAAY,EAClB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,8BAA8B,EAI/B,MAAM,sBAAsB,CAAC;AAM9B,OAAO,EAAE,8BAA8B,EAAE,CAAC;AAW1C,MAAM,WAAW,0BAA0B;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AA+ED;;;;;;;GAOG;AACH,wBAAsB,4BAA4B,CAChD,OAAO,EAAE,0BAA0B,EACnC,QAAQ,CAAC,EAAE,YAAY,GACtB,OAAO,CAAC,qBAAqB,CAAC,CAkGhC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export { fountainPlugin } from "./plugin.js";
|
|
2
2
|
export { fountainSerializer } from "./serializer.js";
|
|
3
|
+
export { observeResourcesDeepFountain } from "./deep-observe.js";
|
|
4
|
+
export type { FountainDeepObserveOptions } from "./deep-observe.js";
|
|
5
|
+
export { fountainDeepNormalizationHooks, FOUNTAIN_SERVER_FIELDS, FOUNTAIN_DEFAULTS, } from "./deep-observe-hooks.js";
|
|
3
6
|
export * from "./generated/index.js";
|
|
4
7
|
export { fountainApply, fountainRun, DEFAULT_FOUNTAIN_BASE_URL } from "./op/activities/index.js";
|
|
5
8
|
export type { FountainApplyArgs, FountainApplySummary, FountainRunArgs, FountainRunResult } from "./op/activities/index.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAIlD,OAAO,EAAE,4BAA4B,EAAE,MAAM,gBAAgB,CAAC;AAC9D,YAAY,EAAE,0BAA0B,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EACL,8BAA8B,EAC9B,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,sBAAsB,CAAC;AAG9B,cAAc,mBAAmB,CAAC;AAIlC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,yBAAyB,EAAE,MAAM,iBAAiB,CAAC;AACxF,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAGnH,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,YAAY,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "3c1bc0c06594180eed030af320ad7a1cf16e5beda1a9c42ec44568e8b1e19368",
|
|
5
5
|
"meta.json": "6666b7a77db9210a329219c6a5e107e5ae8794a34542f7310ce16d9bf95c64f5",
|
|
6
6
|
"types/index.d.ts": "1dfdcba184fffcf464dac7d1d71ef7abed2ae02a8d638c0de50e0b0317b7eb01",
|
|
7
7
|
"rules/ftn001-no-secret-literals.ts": "897a4ce1ec790b3c1540d32892603bd33ff4bf30eb6ba4cd2565dee356d4962e",
|
|
@@ -17,5 +17,5 @@
|
|
|
17
17
|
"skills/chant-fountain-secrets.md": "27e349a91589510a92e518c7d7824a4a322cab5ef242cf5799373a55cbfcd1cd",
|
|
18
18
|
"skills/chant-fountain-locked-sandboxes.md": "de82f06cb3a08ba6bf3ae45fb9869e21d6da18b9ebe0fc769da8aebaceea7dd1"
|
|
19
19
|
},
|
|
20
|
-
"composite": "
|
|
20
|
+
"composite": "3b6b337bb3b931f76ae5ddac80fb34cfaf53ca9d8fcd6084f09c52f0106bb1fe"
|
|
21
21
|
}
|
package/dist/manifest.json
CHANGED
package/dist/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAyB7F;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,aAyK5B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-fountain",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Fountain lexicon for chant — sandboxed agent environments, vaults, and agents as typed estate",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"bundle": "tsx src/package-cli.ts"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"@intentius/chant": "^0.
|
|
53
|
+
"@intentius/chant": "^0.53.0",
|
|
54
54
|
"typescript": "^5.9.3"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fountain deep-observation noise rules (#1217).
|
|
3
|
+
*
|
|
4
|
+
* Plain data, imported statically by `plugin.ts`, because core applies the
|
|
5
|
+
* identical rules to the *declared* property tree — which no reader ever
|
|
6
|
+
* touches — before it diffs. Burying them inside the read would normalize the
|
|
7
|
+
* two sides differently and report everything as drift.
|
|
8
|
+
*
|
|
9
|
+
* ## Why a static table and not an ownership walk
|
|
10
|
+
*
|
|
11
|
+
* Kubernetes records `managedFields`, so the k8s row can subtract whatever a
|
|
12
|
+
* controller owns. A fountain REST payload never says who wrote a field, so
|
|
13
|
+
* this row follows the GCP precedent (`lexicons/gcp/src/deep-observe-hooks.ts`)
|
|
14
|
+
* — a hand-maintained table naming what the server populates. The cost is
|
|
15
|
+
* explicit: a server-set field nobody has listed reads as drift until it is
|
|
16
|
+
* listed. That is visible and fixable; an over-broad rule silently hides real
|
|
17
|
+
* drift, which is the worse of the two failures.
|
|
18
|
+
*
|
|
19
|
+
* Every value below is read off fountain's own Ecto schemas (`Environment`,
|
|
20
|
+
* `Vault`, `Agent`) and the JSON views that render them, not guessed from a
|
|
21
|
+
* sample payload.
|
|
22
|
+
*
|
|
23
|
+
* ## Secrets
|
|
24
|
+
*
|
|
25
|
+
* chant authors an environment's or vault's `secrets` as an ordered
|
|
26
|
+
* `{key, value}[]`, so the declared node holds real secret material. Core's
|
|
27
|
+
* key-name mask (`SENSITIVE_KEY_PATTERNS` matches `secrets`) collapses the
|
|
28
|
+
* whole node to `[REDACTED]` on both trees before any diff sees it, which is
|
|
29
|
+
* the correct outcome and also the limit of what this row can report: presence
|
|
30
|
+
* — an environment that declares no secrets and has some, or the reverse —
|
|
31
|
+
* never the key set, and never a value. Making the key set diffable needs
|
|
32
|
+
* fountain#148's reference model, where secrets stop being inline values.
|
|
33
|
+
*
|
|
34
|
+
* {@link fountainDeepNormalizationHooks.mask} adds one narrow rule on top:
|
|
35
|
+
* a string matching a known credential shape is collapsed wherever it appears,
|
|
36
|
+
* whatever the key is called. FTN001/FTN012 already refuse those at lint time;
|
|
37
|
+
* this is the backstop for a value that reached the live instance some other
|
|
38
|
+
* way, so a drift row can never print one.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type {
|
|
42
|
+
DeepArrayElement,
|
|
43
|
+
DeepNode,
|
|
44
|
+
DeepNormalizationHooks,
|
|
45
|
+
} from "@intentius/chant/deep-observation";
|
|
46
|
+
import { SERVER_FIELDS } from "./import/parser";
|
|
47
|
+
|
|
48
|
+
export const ENVIRONMENT_TYPE = "Fountain::V1::Environment";
|
|
49
|
+
export const VAULT_TYPE = "Fountain::V1::Vault";
|
|
50
|
+
export const AGENT_TYPE = "Fountain::V1::Agent";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Top-level payload fields fountain writes and a caller cannot: the primary
|
|
54
|
+
* key, the `timestamps()` pair, the owning user, the virtual `*_count` rollups
|
|
55
|
+
* the `*_with_counts` reads attach, the avatar's derived media type, and `acp`
|
|
56
|
+
* (computed per request from the runtime, never stored).
|
|
57
|
+
*
|
|
58
|
+
* Pruned on BOTH sides and regardless of what source declared, since a user who
|
|
59
|
+
* writes one is writing something the API overwrites anyway.
|
|
60
|
+
*
|
|
61
|
+
* Matched on the whole pattern rather than its last segment: `name` and `id`
|
|
62
|
+
* also occur *inside* `skills[]` and `repositories[]`, where they are authored
|
|
63
|
+
* configuration. `SERVER_FIELDS` is shared with the import/export path so the
|
|
64
|
+
* two cannot disagree about what a caller may author.
|
|
65
|
+
*/
|
|
66
|
+
export const FOUNTAIN_SERVER_FIELDS: ReadonlySet<string> = new Set([
|
|
67
|
+
...SERVER_FIELDS,
|
|
68
|
+
"secret_count",
|
|
69
|
+
"agent_count",
|
|
70
|
+
"acp",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Per-kind values fountain fills in when the request omits them, keyed by the
|
|
75
|
+
* index-erased pattern from the tree root.
|
|
76
|
+
*
|
|
77
|
+
* Noise only where **source never declared the property** — the
|
|
78
|
+
* `counterpart === "absent"` gate below. A default somebody wrote out
|
|
79
|
+
* explicitly is a fact worth diffing, and a later change away from it has to
|
|
80
|
+
* stay reportable.
|
|
81
|
+
*
|
|
82
|
+
* Straight off the Ecto `schema` blocks. `networking_type: "unrestricted"` is
|
|
83
|
+
* the one worth arguing about: subtracting it means an environment that never
|
|
84
|
+
* states its networking posture does not report one. That case is FTN010's, at
|
|
85
|
+
* lint time, where it is a build finding rather than drift; and the case this
|
|
86
|
+
* row exists for — a reviewed `limited` environment flipped to `unrestricted`
|
|
87
|
+
* in the UI — has a declared counterpart, so the gate keeps it and it reports
|
|
88
|
+
* as `changed`.
|
|
89
|
+
*/
|
|
90
|
+
export const FOUNTAIN_DEFAULTS: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
|
|
91
|
+
[ENVIRONMENT_TYPE]: {
|
|
92
|
+
setup_script: "",
|
|
93
|
+
networking_type: "unrestricted",
|
|
94
|
+
repositories: [],
|
|
95
|
+
},
|
|
96
|
+
[VAULT_TYPE]: {
|
|
97
|
+
description: "",
|
|
98
|
+
},
|
|
99
|
+
[AGENT_TYPE]: {
|
|
100
|
+
description: "",
|
|
101
|
+
system: "",
|
|
102
|
+
skills: [],
|
|
103
|
+
// ADR 0023. Not on the committed spec snapshot yet, so an instance that
|
|
104
|
+
// predates it simply never emits the field.
|
|
105
|
+
sandbox_mode: "ephemeral",
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Credential shapes FTN001 refuses in source and FTN012 refuses in `env_vars`.
|
|
111
|
+
* Reused here as the mask's own rule so the two lists cannot drift apart in
|
|
112
|
+
* what they call a credential.
|
|
113
|
+
*/
|
|
114
|
+
const CREDENTIAL_VALUE_SHAPES: readonly RegExp[] = [
|
|
115
|
+
/^AKIA[0-9A-Z]{16}$/,
|
|
116
|
+
/^(ghp|gho|ghs|ghu)_[A-Za-z0-9]{20,}$/,
|
|
117
|
+
/^github_pat_[A-Za-z0-9_]{20,}$/,
|
|
118
|
+
/^sk-[A-Za-z0-9_-]{20,}$/,
|
|
119
|
+
/^ftn_[A-Za-z0-9]{16,}$/,
|
|
120
|
+
/^xox[baprs]-[A-Za-z0-9-]{10,}$/,
|
|
121
|
+
/^-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
/** Key-order-independent equality, so a default written as a container matches whatever order a payload arrives in. */
|
|
125
|
+
function equalsDefault(expected: unknown, actual: unknown): boolean {
|
|
126
|
+
if (expected === actual) return true;
|
|
127
|
+
if (typeof expected !== typeof actual) return false;
|
|
128
|
+
return canonicalJson(expected) === canonicalJson(actual);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function canonicalJson(value: unknown): string {
|
|
132
|
+
return (
|
|
133
|
+
JSON.stringify(value, (_k, v: unknown) =>
|
|
134
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
135
|
+
? Object.fromEntries(
|
|
136
|
+
Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
137
|
+
)
|
|
138
|
+
: v,
|
|
139
|
+
) ?? ""
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** An `{}` with nothing pruned out of it — fountain's spelling for an unset `:map` column. */
|
|
144
|
+
function isEmptyObject(value: unknown): boolean {
|
|
145
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isEmptyArray(value: unknown): boolean {
|
|
149
|
+
return Array.isArray(value) && value.length === 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The final segment of an index-erased pattern (`skills[].name` -> `name`). */
|
|
153
|
+
function lastSegment(pattern: string): string {
|
|
154
|
+
const withoutIndex = pattern.replace(/\[\]$/, "");
|
|
155
|
+
const dot = withoutIndex.lastIndexOf(".");
|
|
156
|
+
return dot === -1 ? withoutIndex : withoutIndex.slice(dot + 1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const fountainDeepNormalizationHooks: DeepNormalizationHooks = {
|
|
160
|
+
/**
|
|
161
|
+
* A credential-shaped string never reaches a diff row, a log line or a
|
|
162
|
+
* snapshot. Collapsed on both trees, so a value that is the same on both
|
|
163
|
+
* still classifies as unchanged; a changed one classifies as changed without
|
|
164
|
+
* either side being printed. Same contract as the k8s row's Secret mask
|
|
165
|
+
* (#1365 decision 6): presence and paths, never values.
|
|
166
|
+
*/
|
|
167
|
+
mask(node: DeepNode): boolean {
|
|
168
|
+
return typeof node.value === "string" && CREDENTIAL_VALUE_SHAPES.some((p) => p.test(node.value as string));
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
prune(node: DeepNode): boolean {
|
|
172
|
+
// Server-written, on either side, declared or not.
|
|
173
|
+
if (FOUNTAIN_SERVER_FIELDS.has(node.pattern)) return true;
|
|
174
|
+
|
|
175
|
+
// An authored-but-empty `secrets` list is not a fountain state: there is no
|
|
176
|
+
// sub-resource to read back for it, so leaving it on the declared side
|
|
177
|
+
// would report `absent` on every clean read.
|
|
178
|
+
if (lastSegment(node.pattern) === "secrets" && isEmptyArray(node.value)) return true;
|
|
179
|
+
|
|
180
|
+
// Below here: values fountain populated that source never asked for.
|
|
181
|
+
// `counterpart` is a tri-state and only `absent` licenses subtraction.
|
|
182
|
+
if (node.side !== "live" || node.counterpart !== "absent") return false;
|
|
183
|
+
|
|
184
|
+
// `name` is the key the read looked the resource up by, so it can never
|
|
185
|
+
// disagree with the declaration; it is undeclared only when the serializer
|
|
186
|
+
// fell back to the chant export name, which is chant reading back its own
|
|
187
|
+
// choice rather than drift.
|
|
188
|
+
if (node.pattern === "name") return true;
|
|
189
|
+
|
|
190
|
+
// fountain's spellings for "never configured": a nullable column and an
|
|
191
|
+
// unset `:map`. `[]` is deliberately NOT in this rule — an empty
|
|
192
|
+
// `allowed_vault_ids` means "no vault may attach", which is a posture
|
|
193
|
+
// somebody chose, not an absence. The empty-list defaults that ARE server
|
|
194
|
+
// defaults (`skills`, `repositories`) are named per kind below.
|
|
195
|
+
if (node.value === null || isEmptyObject(node.value)) return true;
|
|
196
|
+
|
|
197
|
+
const defaults = FOUNTAIN_DEFAULTS[node.entityType];
|
|
198
|
+
if (!defaults || !Object.prototype.hasOwnProperty.call(defaults, node.pattern)) return false;
|
|
199
|
+
return equalsDefault(defaults[node.pattern], node.value);
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The set-shaped lists. `repositories` and `skills` are keyed by their own
|
|
204
|
+
* identity so one added entry does not rename every entry after it in the
|
|
205
|
+
* flattened diff; the id/host lists are keyed by the element itself.
|
|
206
|
+
*
|
|
207
|
+
* Nothing else is reordered. An array whose elements do not all yield a key
|
|
208
|
+
* keeps the order the payload arrived in, which is the right answer wherever
|
|
209
|
+
* order might carry meaning.
|
|
210
|
+
*/
|
|
211
|
+
orderKey(element: DeepArrayElement): string | undefined {
|
|
212
|
+
const name = lastSegment(element.pattern);
|
|
213
|
+
const el = element.element;
|
|
214
|
+
|
|
215
|
+
if (name === "allowed_vault_ids" || name === "allowed_environment_ids" || name === "allowed_hosts") {
|
|
216
|
+
return typeof el === "string" ? el : undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (name === "repositories" && isRecord(el)) {
|
|
220
|
+
return typeof el.mount_path === "string" ? el.mount_path : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (name === "skills" && isRecord(el)) {
|
|
224
|
+
// An inline entry is identified by its name, a github entry by its repo.
|
|
225
|
+
// Exactly one of the two is set — the server's own changeset enforces it.
|
|
226
|
+
if (typeof el.name === "string") return el.name;
|
|
227
|
+
if (typeof el.source === "string") return el.source;
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return undefined;
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
236
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
237
|
+
}
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fountain deep observation (#1217).
|
|
3
|
+
*
|
|
4
|
+
* The transport is the only thing mocked — a `FountainHttp` routed by
|
|
5
|
+
* `METHOD /path`, the same seam `describe-resources.test.ts` drives — so
|
|
6
|
+
* nothing here opens a socket. The drift assertions run through core's own
|
|
7
|
+
* `diffDeepObservation`, with the lexicon's real hooks on both trees, because
|
|
8
|
+
* the question a noise table has to answer is not "what did the reader return"
|
|
9
|
+
* but "what does a clean apply report".
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import { diffDeepObservation, type DeclaredEntities } from "@intentius/chant/lifecycle/deep-observe";
|
|
14
|
+
import { normalizeDeepObservation, MASKED } from "@intentius/chant/deep-observation";
|
|
15
|
+
import { Environment } from "./generated/index";
|
|
16
|
+
import { observeResourcesDeepFountain, type FountainDeepObserveOptions } from "./deep-observe";
|
|
17
|
+
import { fountainDeepNormalizationHooks } from "./deep-observe-hooks";
|
|
18
|
+
import { fountainPlugin } from "./plugin";
|
|
19
|
+
import type { FountainHttp } from "./op/activities/fountain-apply";
|
|
20
|
+
|
|
21
|
+
const ENV = "Fountain::V1::Environment";
|
|
22
|
+
const VAULT = "Fountain::V1::Vault";
|
|
23
|
+
const AGENT = "Fountain::V1::Agent";
|
|
24
|
+
|
|
25
|
+
const STAMPS = { inserted_at: "2026-08-01T00:00:00Z", updated_at: "2026-08-02T00:00:00Z" };
|
|
26
|
+
|
|
27
|
+
/** A live Environment record as fountain's own JSON view renders it. */
|
|
28
|
+
function liveEnvironment(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
29
|
+
return {
|
|
30
|
+
id: "env-1",
|
|
31
|
+
name: "concierge-env",
|
|
32
|
+
packages: {},
|
|
33
|
+
env_vars: {},
|
|
34
|
+
setup_script: "",
|
|
35
|
+
networking_type: "limited",
|
|
36
|
+
networking_config: { allowed_hosts: ["api.github.com"] },
|
|
37
|
+
repositories: [],
|
|
38
|
+
metadata: { "managed-by": "chant" },
|
|
39
|
+
secret_count: 0,
|
|
40
|
+
agent_count: 1,
|
|
41
|
+
...STAMPS,
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function liveVault(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
47
|
+
return {
|
|
48
|
+
id: "vault-1",
|
|
49
|
+
name: "ops-vault",
|
|
50
|
+
description: "",
|
|
51
|
+
metadata: { "managed-by": "chant" },
|
|
52
|
+
secret_count: 0,
|
|
53
|
+
...STAMPS,
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function liveAgent(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
59
|
+
return {
|
|
60
|
+
id: "agent-1",
|
|
61
|
+
name: "researcher",
|
|
62
|
+
description: "",
|
|
63
|
+
system: "You research things.",
|
|
64
|
+
model: "anthropic/claude-sonnet-4-6",
|
|
65
|
+
runtime: "claude",
|
|
66
|
+
acp: true,
|
|
67
|
+
sandbox_provider: null,
|
|
68
|
+
sandbox_mode: "ephemeral",
|
|
69
|
+
environment_id: "env-1",
|
|
70
|
+
skills: [{ source: "acme/skills", ref: "v1.2.0" }],
|
|
71
|
+
mcp_servers: {},
|
|
72
|
+
metadata: { "managed-by": "chant" },
|
|
73
|
+
allowed_vault_ids: [],
|
|
74
|
+
allowed_environment_ids: null,
|
|
75
|
+
permission_policy: {},
|
|
76
|
+
conversation_count: 3,
|
|
77
|
+
avatar_media_type: null,
|
|
78
|
+
...STAMPS,
|
|
79
|
+
...overrides,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface Route {
|
|
84
|
+
status: number;
|
|
85
|
+
json?: unknown;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function routed(routes: Record<string, Route>): FountainHttp {
|
|
89
|
+
return async (method, path) => {
|
|
90
|
+
const hit = routes[`${method} ${path}`];
|
|
91
|
+
if (!hit) throw new Error(`unrouted: ${method} ${path}`);
|
|
92
|
+
return { status: hit.status, json: hit.json ?? null };
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The default estate: one environment, one vault, one agent, no secrets. */
|
|
97
|
+
function estate(overrides: Record<string, Route> = {}): FountainHttp {
|
|
98
|
+
return routed({
|
|
99
|
+
"GET /api/environments": { status: 200, json: { data: [liveEnvironment()] } },
|
|
100
|
+
"GET /api/vaults": { status: 200, json: { data: [liveVault()] } },
|
|
101
|
+
"GET /api/agents": { status: 200, json: { data: [liveAgent()] } },
|
|
102
|
+
"GET /api/environments/env-1/secrets": { status: 200, json: { data: [] } },
|
|
103
|
+
"GET /api/vaults/vault-1/secrets": { status: 200, json: { data: [] } },
|
|
104
|
+
...overrides,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function declared(defs: Record<string, { entityType: string; props: Record<string, unknown> }>): DeclaredEntities {
|
|
109
|
+
return new Map(Object.entries(defs));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function options(entities: DeclaredEntities, extra?: Partial<FountainDeepObserveOptions>): FountainDeepObserveOptions {
|
|
113
|
+
return {
|
|
114
|
+
environment: "local",
|
|
115
|
+
buildOutput: "",
|
|
116
|
+
entityNames: [...entities.keys()],
|
|
117
|
+
entities,
|
|
118
|
+
...extra,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Read live, then diff against the declaration with the lexicon's own hooks. */
|
|
123
|
+
async function drift(entities: DeclaredEntities, http: FountainHttp, extra?: Partial<FountainDeepObserveOptions>) {
|
|
124
|
+
const live = normalizeDeepObservation(await observeResourcesDeepFountain(options(entities, extra), http));
|
|
125
|
+
return { live, diff: diffDeepObservation(entities, live, fountainDeepNormalizationHooks) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The declaration the estate above was applied from. `environment` is the
|
|
129
|
+
// typed reference a chant project writes, not the id fountain resolved it to.
|
|
130
|
+
const conciergeEnvironment = new Environment({
|
|
131
|
+
name: "concierge-env",
|
|
132
|
+
networking_type: "limited",
|
|
133
|
+
networking_config: { allowed_hosts: ["api.github.com"] },
|
|
134
|
+
metadata: { "managed-by": "chant" },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
function conciergeDeclaration(): DeclaredEntities {
|
|
138
|
+
return declared({
|
|
139
|
+
conciergeEnv: {
|
|
140
|
+
entityType: ENV,
|
|
141
|
+
props: {
|
|
142
|
+
name: "concierge-env",
|
|
143
|
+
networking_type: "limited",
|
|
144
|
+
networking_config: { allowed_hosts: ["api.github.com"] },
|
|
145
|
+
metadata: { "managed-by": "chant" },
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
opsVault: {
|
|
149
|
+
entityType: VAULT,
|
|
150
|
+
props: { name: "ops-vault", metadata: { "managed-by": "chant" } },
|
|
151
|
+
},
|
|
152
|
+
researcher: {
|
|
153
|
+
entityType: AGENT,
|
|
154
|
+
props: {
|
|
155
|
+
name: "researcher",
|
|
156
|
+
model: "anthropic/claude-sonnet-4-6",
|
|
157
|
+
runtime: "claude",
|
|
158
|
+
system: "You research things.",
|
|
159
|
+
environment: conciergeEnvironment,
|
|
160
|
+
skills: [{ source: "acme/skills", ref: "v1.2.0" }],
|
|
161
|
+
allowed_vault_ids: [],
|
|
162
|
+
metadata: { "managed-by": "chant" },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
describe("a clean apply reports nothing", () => {
|
|
169
|
+
it("every server-populated field and every untouched default is subtracted", async () => {
|
|
170
|
+
const { diff } = await drift(conciergeDeclaration(), estate());
|
|
171
|
+
|
|
172
|
+
expect(diff.drifted).toEqual([]);
|
|
173
|
+
expect(diff.unobserved).toEqual([]);
|
|
174
|
+
expect(diff.unchanged.sort()).toEqual(["conciergeEnv", "opsVault", "researcher"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("the returned tree carries no ids or timestamps", async () => {
|
|
178
|
+
const { live } = await drift(conciergeDeclaration(), estate());
|
|
179
|
+
|
|
180
|
+
for (const observed of Object.values(live.resources)) {
|
|
181
|
+
for (const key of ["id", "inserted_at", "updated_at", "secret_count", "agent_count", "acp", "conversation_count"]) {
|
|
182
|
+
expect(Object.keys(observed.properties)).not.toContain(key);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// The physical id is reported on the envelope, where it belongs.
|
|
186
|
+
expect(live.resources.conciergeEnv.physicalId).toBe("env-1");
|
|
187
|
+
expect(live.resources.researcher.physicalId).toBe("agent-1");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("a reordered repository list is not drift", async () => {
|
|
191
|
+
const entities = declared({
|
|
192
|
+
env: {
|
|
193
|
+
entityType: ENV,
|
|
194
|
+
props: {
|
|
195
|
+
name: "concierge-env",
|
|
196
|
+
networking_type: "limited",
|
|
197
|
+
metadata: { "managed-by": "chant" },
|
|
198
|
+
repositories: [
|
|
199
|
+
{ url: "https://example.com/a.git", mount_path: "/a" },
|
|
200
|
+
{ url: "https://example.com/b.git", mount_path: "/b" },
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
const http = estate({
|
|
206
|
+
"GET /api/environments": {
|
|
207
|
+
status: 200,
|
|
208
|
+
json: {
|
|
209
|
+
data: [
|
|
210
|
+
liveEnvironment({
|
|
211
|
+
networking_config: {},
|
|
212
|
+
repositories: [
|
|
213
|
+
{ url: "https://example.com/b.git", mount_path: "/b" },
|
|
214
|
+
{ url: "https://example.com/a.git", mount_path: "/a" },
|
|
215
|
+
],
|
|
216
|
+
}),
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const { diff } = await drift(entities, http);
|
|
223
|
+
expect(diff.drifted).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("the drift the design was written for", () => {
|
|
228
|
+
it("a UI flip from limited to unrestricted reports as a changed property", async () => {
|
|
229
|
+
const http = estate({
|
|
230
|
+
"GET /api/environments": {
|
|
231
|
+
status: 200,
|
|
232
|
+
json: { data: [liveEnvironment({ networking_type: "unrestricted", networking_config: {} })] },
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const { diff } = await drift(conciergeDeclaration(), http);
|
|
237
|
+
const env = diff.drifted.find((d) => d.name === "conciergeEnv");
|
|
238
|
+
expect(env?.changes).toContainEqual(
|
|
239
|
+
expect.objectContaining({ path: "networking_type", kind: "changed", declared: "limited", live: "unrestricted" }),
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("an egress allowlist widened in the UI reports the added host", async () => {
|
|
244
|
+
const http = estate({
|
|
245
|
+
"GET /api/environments": {
|
|
246
|
+
status: 200,
|
|
247
|
+
json: {
|
|
248
|
+
data: [liveEnvironment({ networking_config: { allowed_hosts: ["api.github.com", "evil.example.com"] } })],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const { diff } = await drift(conciergeDeclaration(), http);
|
|
254
|
+
const env = diff.drifted.find((d) => d.name === "conciergeEnv");
|
|
255
|
+
expect(env?.changes.some((c) => c.kind === "undeclared" && c.live === "evil.example.com")).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("a vault allowlist widened from none to any reports as drift", async () => {
|
|
259
|
+
const http = estate({
|
|
260
|
+
"GET /api/agents": { status: 200, json: { data: [liveAgent({ allowed_vault_ids: null })] } },
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const { diff } = await drift(conciergeDeclaration(), http);
|
|
264
|
+
const agent = diff.drifted.find((d) => d.name === "researcher");
|
|
265
|
+
// The declared `[]` (no vault may attach) is gone live. `null` is
|
|
266
|
+
// fountain's legacy-permissive state, and it must not be pruned as an
|
|
267
|
+
// unset column when source declared the field.
|
|
268
|
+
expect(agent?.changes).toContainEqual(
|
|
269
|
+
expect.objectContaining({ path: "allowed_vault_ids", kind: "changed", live: null }),
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("a skill unpinned from its ref reports the lost pin", async () => {
|
|
274
|
+
const http = estate({
|
|
275
|
+
"GET /api/agents": { status: 200, json: { data: [liveAgent({ skills: [{ source: "acme/skills" }] })] } },
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const { diff } = await drift(conciergeDeclaration(), http);
|
|
279
|
+
const agent = diff.drifted.find((d) => d.name === "researcher");
|
|
280
|
+
expect(agent?.changes).toContainEqual(
|
|
281
|
+
expect.objectContaining({ kind: "absent", declared: "v1.2.0" }),
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("secrets: presence classifies, values and keys never leave fountain", () => {
|
|
287
|
+
it("a secret added to an environment that declares none reports as undeclared", async () => {
|
|
288
|
+
const http = estate({
|
|
289
|
+
"GET /api/environments/env-1/secrets": {
|
|
290
|
+
status: 200,
|
|
291
|
+
json: { data: [{ id: "s-1", key: "STRIPE_KEY", environment_id: "env-1", ...STAMPS }] },
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const { live, diff } = await drift(conciergeDeclaration(), http);
|
|
296
|
+
const env = diff.drifted.find((d) => d.name === "conciergeEnv");
|
|
297
|
+
expect(env?.changes).toContainEqual(
|
|
298
|
+
expect.objectContaining({ path: "secrets", kind: "undeclared", live: MASKED }),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// Not the value — fountain never returns one — and not the key either:
|
|
302
|
+
// core's key-name mask collapses the whole node on both trees.
|
|
303
|
+
expect(JSON.stringify(live.resources.conciergeEnv.properties)).not.toContain("STRIPE_KEY");
|
|
304
|
+
expect(JSON.stringify(diff.drifted)).not.toContain("STRIPE_KEY");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("declared secrets against live secrets is unchanged, and no value is compared", async () => {
|
|
308
|
+
const entities = declared({
|
|
309
|
+
env: {
|
|
310
|
+
entityType: ENV,
|
|
311
|
+
props: {
|
|
312
|
+
name: "concierge-env",
|
|
313
|
+
networking_type: "limited",
|
|
314
|
+
networking_config: { allowed_hosts: ["api.github.com"] },
|
|
315
|
+
metadata: { "managed-by": "chant" },
|
|
316
|
+
secrets: [{ key: "STRIPE_KEY", value: "${STRIPE_KEY}" }],
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
const http = estate({
|
|
321
|
+
"GET /api/environments/env-1/secrets": {
|
|
322
|
+
status: 200,
|
|
323
|
+
json: { data: [{ id: "s-1", key: "STRIPE_KEY", environment_id: "env-1", ...STAMPS }] },
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const { diff } = await drift(entities, http);
|
|
328
|
+
expect(diff.drifted).toEqual([]);
|
|
329
|
+
expect(diff.unchanged).toEqual(["env"]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("an authored-but-empty secrets list is not reported absent", async () => {
|
|
333
|
+
const entities = declared({
|
|
334
|
+
env: {
|
|
335
|
+
entityType: ENV,
|
|
336
|
+
props: {
|
|
337
|
+
name: "concierge-env",
|
|
338
|
+
networking_type: "limited",
|
|
339
|
+
networking_config: { allowed_hosts: ["api.github.com"] },
|
|
340
|
+
metadata: { "managed-by": "chant" },
|
|
341
|
+
secrets: [],
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const { diff } = await drift(entities, estate());
|
|
347
|
+
expect(diff.drifted).toEqual([]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("a credential-shaped value is masked on both sides", async () => {
|
|
351
|
+
const entities = declared({
|
|
352
|
+
env: {
|
|
353
|
+
entityType: ENV,
|
|
354
|
+
props: {
|
|
355
|
+
name: "concierge-env",
|
|
356
|
+
networking_type: "limited",
|
|
357
|
+
networking_config: { allowed_hosts: ["api.github.com"] },
|
|
358
|
+
metadata: { "managed-by": "chant" },
|
|
359
|
+
env_vars: { DEPLOY_KEY: "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" },
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
const http = estate({
|
|
364
|
+
"GET /api/environments": {
|
|
365
|
+
status: 200,
|
|
366
|
+
json: { data: [liveEnvironment({ env_vars: { DEPLOY_KEY: "ghp_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" } })] },
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const { live, diff } = await drift(entities, http);
|
|
371
|
+
expect((live.resources.env.properties.env_vars as Record<string, unknown>).DEPLOY_KEY).toBe(MASKED);
|
|
372
|
+
// Both sides collapse, so a rotated credential reads as unchanged rather
|
|
373
|
+
// than printing either value.
|
|
374
|
+
expect(diff.drifted).toEqual([]);
|
|
375
|
+
expect(JSON.stringify(diff)).not.toContain("ghp_");
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe("the agent's environment reference", () => {
|
|
380
|
+
it("resolves the server-assigned id back to the environment name", async () => {
|
|
381
|
+
const { live } = await drift(conciergeDeclaration(), estate());
|
|
382
|
+
expect(live.resources.researcher.properties.environment).toBe("concierge-env");
|
|
383
|
+
expect(live.resources.researcher.properties.environment_id).toBeUndefined();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("passes the id through where source authored the id itself", async () => {
|
|
387
|
+
const entities = declared({
|
|
388
|
+
researcher: {
|
|
389
|
+
entityType: AGENT,
|
|
390
|
+
props: {
|
|
391
|
+
name: "researcher",
|
|
392
|
+
model: "anthropic/claude-sonnet-4-6",
|
|
393
|
+
runtime: "claude",
|
|
394
|
+
system: "You research things.",
|
|
395
|
+
environment_id: "env-1",
|
|
396
|
+
skills: [{ source: "acme/skills", ref: "v1.2.0" }],
|
|
397
|
+
allowed_vault_ids: [],
|
|
398
|
+
metadata: { "managed-by": "chant" },
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const { live, diff } = await drift(entities, estate());
|
|
404
|
+
expect(live.resources.researcher.properties.environment_id).toBe("env-1");
|
|
405
|
+
expect(live.resources.researcher.properties.environment).toBeUndefined();
|
|
406
|
+
expect(diff.drifted).toEqual([]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("an environment attached to an agent that declares none reports as undeclared", async () => {
|
|
410
|
+
const entities = declared({
|
|
411
|
+
researcher: {
|
|
412
|
+
entityType: AGENT,
|
|
413
|
+
props: {
|
|
414
|
+
name: "researcher",
|
|
415
|
+
model: "anthropic/claude-sonnet-4-6",
|
|
416
|
+
runtime: "claude",
|
|
417
|
+
system: "You research things.",
|
|
418
|
+
skills: [{ source: "acme/skills", ref: "v1.2.0" }],
|
|
419
|
+
allowed_vault_ids: [],
|
|
420
|
+
metadata: { "managed-by": "chant" },
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const { diff } = await drift(entities, estate());
|
|
426
|
+
expect(diff.drifted[0]?.changes).toContainEqual(
|
|
427
|
+
expect.objectContaining({ path: "environment", kind: "undeclared", live: "concierge-env" }),
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe("holes are holes, not clean trees", () => {
|
|
433
|
+
it("a missing token reports no-credentials for every entity and observes nothing", async () => {
|
|
434
|
+
const saved = process.env.FOUNTAIN_TOKEN;
|
|
435
|
+
delete process.env.FOUNTAIN_TOKEN;
|
|
436
|
+
try {
|
|
437
|
+
const result = normalizeDeepObservation(
|
|
438
|
+
await observeResourcesDeepFountain(options(conciergeDeclaration())),
|
|
439
|
+
);
|
|
440
|
+
expect(result.resources).toEqual({});
|
|
441
|
+
expect(Object.values(result.unobserved).map((u) => u.reason)).toEqual([
|
|
442
|
+
"no-credentials",
|
|
443
|
+
"no-credentials",
|
|
444
|
+
"no-credentials",
|
|
445
|
+
]);
|
|
446
|
+
} finally {
|
|
447
|
+
if (saved !== undefined) process.env.FOUNTAIN_TOKEN = saved;
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("a failed kind list marks only that kind read-failed", async () => {
|
|
452
|
+
const http = estate({ "GET /api/vaults": { status: 500 } });
|
|
453
|
+
const { live } = await drift(conciergeDeclaration(), http);
|
|
454
|
+
|
|
455
|
+
expect(live.unobserved.opsVault.reason).toBe("read-failed");
|
|
456
|
+
expect(live.resources.opsVault).toBeUndefined();
|
|
457
|
+
expect(live.resources.conciergeEnv).toBeDefined();
|
|
458
|
+
expect(live.resources.researcher).toBeDefined();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("a failed secrets listing makes the whole entity a hole", async () => {
|
|
462
|
+
const http = estate({ "GET /api/environments/env-1/secrets": { status: 503 } });
|
|
463
|
+
const { live } = await drift(conciergeDeclaration(), http);
|
|
464
|
+
|
|
465
|
+
// Reporting the rest of the environment's properties as clean would be a
|
|
466
|
+
// claim that its secrets did not drift, which this read cannot make.
|
|
467
|
+
expect(live.unobserved.conciergeEnv.reason).toBe("read-failed");
|
|
468
|
+
expect(live.resources.conciergeEnv).toBeUndefined();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("an entity absent from the estate is left to the thin read, not double-reported", async () => {
|
|
472
|
+
const entities = declared({ gone: { entityType: VAULT, props: { name: "not-there" } } });
|
|
473
|
+
const { live } = await drift(entities, estate());
|
|
474
|
+
|
|
475
|
+
expect(live.resources.gone).toBeUndefined();
|
|
476
|
+
expect(live.unobserved.gone).toBeUndefined();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("a kind with no reader is unsupported-kind, never an absence", async () => {
|
|
480
|
+
const entities = declared({ chat: { entityType: "Fountain::V1::Conversation", props: {} } });
|
|
481
|
+
const { live } = await drift(entities, estate());
|
|
482
|
+
|
|
483
|
+
expect(live.unobserved.chat.reason).toBe("unsupported-kind");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("owned:true withholds an unmarked resource as filtered", async () => {
|
|
487
|
+
const http = estate({
|
|
488
|
+
"GET /api/environments": { status: 200, json: { data: [liveEnvironment({ metadata: {} })] } },
|
|
489
|
+
});
|
|
490
|
+
const entities = declared({ conciergeEnv: { entityType: ENV, props: { name: "concierge-env" } } });
|
|
491
|
+
const { live } = await drift(entities, http, { owned: true });
|
|
492
|
+
|
|
493
|
+
expect(live.resources.conciergeEnv).toBeUndefined();
|
|
494
|
+
expect(live.unobserved.conciergeEnv.reason).toBe("filtered");
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
describe("plugin wiring", () => {
|
|
499
|
+
it("exposes the reader and the hooks core needs for the declared tree", () => {
|
|
500
|
+
expect(typeof fountainPlugin.observeResourcesDeep).toBe("function");
|
|
501
|
+
expect(fountainPlugin.deepNormalizationHooks).toBe(fountainDeepNormalizationHooks);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fountain deep observation (#1217) — the fountain row of the deep-observe
|
|
3
|
+
* contract (#1014).
|
|
4
|
+
*
|
|
5
|
+
* `describeResources()` (./describe-resources.ts) answers whether a declared
|
|
6
|
+
* Environment/Vault/Agent exists and hands back its id and timestamps. That
|
|
7
|
+
* misses the drift the design was written for: an environment hand-edited in
|
|
8
|
+
* the fountain UI from `networking_type: limited` to `unrestricted`, an
|
|
9
|
+
* `allowed_vault_ids` allowlist widened, a skill repointed at an unpinned
|
|
10
|
+
* branch, a secret added to a reviewed sandbox. All of it lives one level
|
|
11
|
+
* down, in properties nobody was reading.
|
|
12
|
+
*
|
|
13
|
+
* ## The read is the thin path's read
|
|
14
|
+
*
|
|
15
|
+
* Transport, endpoint and auth are the applier's, unchanged
|
|
16
|
+
* (`FOUNTAIN_ENDPOINT` / `FOUNTAIN_TOKEN`), so plan reads the instance
|
|
17
|
+
* `fountainApply` writes. And the depth is free: fountain's list endpoints
|
|
18
|
+
* render the full record — `GET /api/environments` returns every configuration
|
|
19
|
+
* field the request schema accepts, not a summary — so there is no per-resource
|
|
20
|
+
* follow-up GET the way the AWS row needs Cloud Control on top of
|
|
21
|
+
* `describe-stack-resources`. One list per declared kind, cached, exactly as
|
|
22
|
+
* the thin path does it.
|
|
23
|
+
*
|
|
24
|
+
* ## The payload passes through
|
|
25
|
+
*
|
|
26
|
+
* fountain's JSON views name their fields the same way the request schema does
|
|
27
|
+
* (`networking_type`, `env_vars`, `skills`), so the live tree and the declared
|
|
28
|
+
* tree already speak one vocabulary — the AWS situation, not temporal's. The
|
|
29
|
+
* payload is therefore forwarded as-is and the noise rules
|
|
30
|
+
* (./deep-observe-hooks.ts) do the rest. A field fountain adds in a later
|
|
31
|
+
* release surfaces as `undeclared` until the table names it, which is the
|
|
32
|
+
* deliberate trade: visible and fixable beats silently dropped.
|
|
33
|
+
*
|
|
34
|
+
* One exception, and it is the reference edge. chant declares an agent's
|
|
35
|
+
* environment as a typed reference (`environment`), fountain stores the id it
|
|
36
|
+
* resolved to (`environment_id`). Passing the id through would report
|
|
37
|
+
* `<undeclared> -> <uuid>` on every clean read, so where source did not author
|
|
38
|
+
* `environment_id` itself the id is resolved back to the environment's name and
|
|
39
|
+
* emitted as `environment` — the same translation `exportResources()` does for
|
|
40
|
+
* the import path.
|
|
41
|
+
*
|
|
42
|
+
* ## Secrets: presence, never keys, never values
|
|
43
|
+
*
|
|
44
|
+
* Values are write-only upstream and are never read here at all. The secrets
|
|
45
|
+
* sub-resource is listed (keys and timestamps only) so that an environment or
|
|
46
|
+
* vault which declares no secrets and has some — somebody adding one to a
|
|
47
|
+
* locked-down sandbox — reports as drift. Core's key-name mask collapses the
|
|
48
|
+
* whole `secrets` node on both trees, so what a diff row can say is that
|
|
49
|
+
* secrets exist, not which. See the hooks module for why the key set itself is
|
|
50
|
+
* not expressible until fountain#148 lands.
|
|
51
|
+
*
|
|
52
|
+
* That listing is one extra request per observed Environment and Vault. A
|
|
53
|
+
* fountain tenant holds a handful of each, and the alternative — inferring
|
|
54
|
+
* presence from the newer payload's `secret_count` — would silently report
|
|
55
|
+
* "no secrets" against any instance predating that field.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import type {
|
|
59
|
+
DeepObservationResult,
|
|
60
|
+
DeepResourceObservation,
|
|
61
|
+
UnobservedEntity,
|
|
62
|
+
} from "@intentius/chant/lexicon";
|
|
63
|
+
import { deepObservation, normalizeDeepProperties } from "@intentius/chant/deep-observation";
|
|
64
|
+
import { unobservedAll } from "@intentius/chant/observation";
|
|
65
|
+
import {
|
|
66
|
+
resolveEndpoint,
|
|
67
|
+
defaultFountainHttp,
|
|
68
|
+
isChantOwned,
|
|
69
|
+
OWNERSHIP_KEY,
|
|
70
|
+
OWNERSHIP_VALUE,
|
|
71
|
+
type FountainHttp,
|
|
72
|
+
} from "./op/activities/fountain-apply";
|
|
73
|
+
import {
|
|
74
|
+
fountainDeepNormalizationHooks,
|
|
75
|
+
ENVIRONMENT_TYPE,
|
|
76
|
+
VAULT_TYPE,
|
|
77
|
+
AGENT_TYPE,
|
|
78
|
+
} from "./deep-observe-hooks";
|
|
79
|
+
|
|
80
|
+
// Re-exported so a dynamic importer of this module gets the reader and its
|
|
81
|
+
// hooks from one place. `plugin.ts` imports the hooks separately and
|
|
82
|
+
// statically, because core normalizes the declared tree with them whether or
|
|
83
|
+
// not a live read ever happens.
|
|
84
|
+
export { fountainDeepNormalizationHooks };
|
|
85
|
+
|
|
86
|
+
const KIND_PATHS: Record<string, string> = {
|
|
87
|
+
[ENVIRONMENT_TYPE]: "environments",
|
|
88
|
+
[VAULT_TYPE]: "vaults",
|
|
89
|
+
[AGENT_TYPE]: "agents",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Kinds whose secrets live in a sub-resource rather than the record itself. */
|
|
93
|
+
const SECRET_BEARING: ReadonlySet<string> = new Set([ENVIRONMENT_TYPE, VAULT_TYPE]);
|
|
94
|
+
|
|
95
|
+
export interface FountainDeepObserveOptions {
|
|
96
|
+
environment: string;
|
|
97
|
+
buildOutput?: string;
|
|
98
|
+
entityNames: string[];
|
|
99
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
100
|
+
stack?: string;
|
|
101
|
+
/** Restrict to resources carrying the `managed-by: chant` marker. */
|
|
102
|
+
owned?: boolean;
|
|
103
|
+
/** Endpoint override (tests). Defaults to resolveEndpoint(). */
|
|
104
|
+
endpoint?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The fields this reader reads by name off a live record. Everything else passes through. */
|
|
108
|
+
interface LiveRecord extends Record<string, unknown> {
|
|
109
|
+
id: string;
|
|
110
|
+
name: string;
|
|
111
|
+
metadata?: Record<string, unknown>;
|
|
112
|
+
environment_id?: string | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One list per kind, shared by every entity of that kind — including a failure. */
|
|
116
|
+
class KindLists {
|
|
117
|
+
private readonly lists = new Map<string, Promise<Map<string, LiveRecord>>>();
|
|
118
|
+
|
|
119
|
+
constructor(private readonly http: FountainHttp) {}
|
|
120
|
+
|
|
121
|
+
byName(entityType: string): Promise<Map<string, LiveRecord>> {
|
|
122
|
+
const cached = this.lists.get(entityType);
|
|
123
|
+
if (cached) return cached;
|
|
124
|
+
|
|
125
|
+
const path = KIND_PATHS[entityType];
|
|
126
|
+
const pending = (async () => {
|
|
127
|
+
const { status, json } = await this.http("GET", `/api/${path}`);
|
|
128
|
+
if (status !== 200) throw new Error(`list ${path} returned ${status}`);
|
|
129
|
+
const data = (json as { data?: LiveRecord[] })?.data ?? [];
|
|
130
|
+
return new Map(data.map((r) => [r.name, r]));
|
|
131
|
+
})();
|
|
132
|
+
|
|
133
|
+
this.lists.set(entityType, pending);
|
|
134
|
+
return pending;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The keys of a resource's secrets, sorted. Never the values — the API does not
|
|
140
|
+
* return them and this never asks. Returns `undefined` when there are none, so
|
|
141
|
+
* an environment without secrets carries no `secrets` path at all: an empty
|
|
142
|
+
* list is itself a value, and reporting one against a declaration that has none
|
|
143
|
+
* would be noise wearing the shape of drift.
|
|
144
|
+
*/
|
|
145
|
+
async function secretKeys(
|
|
146
|
+
http: FountainHttp,
|
|
147
|
+
kindPath: string,
|
|
148
|
+
id: string,
|
|
149
|
+
): Promise<Array<{ key: string }> | undefined> {
|
|
150
|
+
const { status, json } = await http("GET", `/api/${kindPath}/${id}/secrets`);
|
|
151
|
+
if (status !== 200) throw new Error(`list ${kindPath}/${id}/secrets returned ${status}`);
|
|
152
|
+
const data = (json as { data?: Array<{ key?: unknown }> })?.data ?? [];
|
|
153
|
+
const keys = data
|
|
154
|
+
.map((s) => s.key)
|
|
155
|
+
.filter((k): k is string => typeof k === "string")
|
|
156
|
+
.sort();
|
|
157
|
+
return keys.length > 0 ? keys.map((key) => ({ key })) : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The live property tree for an agent, with the reference edge put back into
|
|
162
|
+
* the vocabulary source writes it in (see the module doc).
|
|
163
|
+
*/
|
|
164
|
+
function agentProperties(
|
|
165
|
+
record: LiveRecord,
|
|
166
|
+
declared: Record<string, unknown>,
|
|
167
|
+
environmentNameById: Map<string, string>,
|
|
168
|
+
): Record<string, unknown> {
|
|
169
|
+
const tree: Record<string, unknown> = { ...record };
|
|
170
|
+
if (declared.environment_id !== undefined) return tree;
|
|
171
|
+
|
|
172
|
+
const id = record.environment_id;
|
|
173
|
+
if (typeof id !== "string") return tree;
|
|
174
|
+
const name = environmentNameById.get(id);
|
|
175
|
+
// An id with no environment behind it should not exist (the column carries a
|
|
176
|
+
// foreign key), but if it does, the raw id is the honest thing to report.
|
|
177
|
+
if (!name) return tree;
|
|
178
|
+
|
|
179
|
+
delete tree.environment_id;
|
|
180
|
+
tree.environment = name;
|
|
181
|
+
return tree;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Read the live property tree for each declared fountain entity.
|
|
186
|
+
*
|
|
187
|
+
* `http` is injectable for tests; the default reuses the applier's fetch client
|
|
188
|
+
* (bearer token from FOUNTAIN_TOKEN). A missing token is the whole-lexicon
|
|
189
|
+
* failure the thin path already names — every declared entity NOT-OBSERVED with
|
|
190
|
+
* `no-credentials`, never an empty tree, which would read as "nothing drifted".
|
|
191
|
+
*/
|
|
192
|
+
export async function observeResourcesDeepFountain(
|
|
193
|
+
options: FountainDeepObserveOptions,
|
|
194
|
+
injected?: FountainHttp,
|
|
195
|
+
): Promise<DeepObservationResult> {
|
|
196
|
+
const names = [...options.entities.keys()];
|
|
197
|
+
|
|
198
|
+
let http = injected;
|
|
199
|
+
if (!http) {
|
|
200
|
+
const token = process.env.FOUNTAIN_TOKEN;
|
|
201
|
+
if (!token) {
|
|
202
|
+
return deepObservation(
|
|
203
|
+
{},
|
|
204
|
+
unobservedAll(
|
|
205
|
+
names,
|
|
206
|
+
"no-credentials",
|
|
207
|
+
"FOUNTAIN_TOKEN is not set — cannot read live fountain state",
|
|
208
|
+
options.entities,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
http = defaultFountainHttp(resolveEndpoint({ endpoint: options.endpoint }), token);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const lists = new KindLists(http);
|
|
216
|
+
const resources: Record<string, DeepResourceObservation> = {};
|
|
217
|
+
const unobserved: Record<string, UnobservedEntity> = {};
|
|
218
|
+
|
|
219
|
+
// Built lazily and only for agents, so a project that declares no agent never
|
|
220
|
+
// pays for the environments list it would not otherwise read.
|
|
221
|
+
let environmentNameById: Map<string, string> | undefined;
|
|
222
|
+
const environmentNames = async (): Promise<Map<string, string>> => {
|
|
223
|
+
if (!environmentNameById) {
|
|
224
|
+
const byName = await lists.byName(ENVIRONMENT_TYPE);
|
|
225
|
+
environmentNameById = new Map([...byName.values()].map((r) => [r.id, r.name]));
|
|
226
|
+
}
|
|
227
|
+
return environmentNameById;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
for (const [entityName, { entityType, props }] of options.entities) {
|
|
231
|
+
if (!(entityType in KIND_PATHS)) {
|
|
232
|
+
unobserved[entityName] = {
|
|
233
|
+
type: entityType,
|
|
234
|
+
reason: "unsupported-kind",
|
|
235
|
+
detail: `no fountain deep reader for ${entityType}`,
|
|
236
|
+
};
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const resourceName = typeof props.name === "string" ? props.name : entityName;
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const byName = await lists.byName(entityType);
|
|
244
|
+
const record = byName.get(resourceName);
|
|
245
|
+
|
|
246
|
+
// Not deployed. The thin read already reports the absence (#1089);
|
|
247
|
+
// restating it here as a property hole would turn one finding into two.
|
|
248
|
+
if (!record) continue;
|
|
249
|
+
|
|
250
|
+
if (options.owned && !isChantOwned(record)) {
|
|
251
|
+
unobserved[entityName] = {
|
|
252
|
+
type: entityType,
|
|
253
|
+
reason: "filtered",
|
|
254
|
+
detail: `"${resourceName}" exists but does not carry the ${OWNERSHIP_KEY}: ${OWNERSHIP_VALUE} marker`,
|
|
255
|
+
};
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let tree: Record<string, unknown>;
|
|
260
|
+
if (entityType === AGENT_TYPE) {
|
|
261
|
+
tree = agentProperties(record, props, await environmentNames());
|
|
262
|
+
} else {
|
|
263
|
+
tree = { ...record };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (SECRET_BEARING.has(entityType)) {
|
|
267
|
+
const secrets = await secretKeys(http, KIND_PATHS[entityType], record.id);
|
|
268
|
+
if (secrets) tree.secrets = secrets;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
resources[entityName] = {
|
|
272
|
+
type: entityType,
|
|
273
|
+
physicalId: record.id,
|
|
274
|
+
properties: normalizeDeepProperties(tree, {
|
|
275
|
+
entityType,
|
|
276
|
+
side: "live",
|
|
277
|
+
hooks: fountainDeepNormalizationHooks,
|
|
278
|
+
}),
|
|
279
|
+
};
|
|
280
|
+
} catch (err) {
|
|
281
|
+
// Per-entity, with the reason. A partial property surface — a failed
|
|
282
|
+
// secrets listing, a failed kind list — must never arrive as a clean
|
|
283
|
+
// tree, because a clean tree is a claim that nothing drifted.
|
|
284
|
+
unobserved[entityName] = {
|
|
285
|
+
type: entityType,
|
|
286
|
+
reason: "read-failed",
|
|
287
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return deepObservation(resources, unobserved);
|
|
293
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,16 @@ export { fountainPlugin } from "./plugin";
|
|
|
4
4
|
// Serializer
|
|
5
5
|
export { fountainSerializer } from "./serializer";
|
|
6
6
|
|
|
7
|
+
// Deep observation (#1217) — the reader plus the noise rules it shares with
|
|
8
|
+
// core's normalization pass.
|
|
9
|
+
export { observeResourcesDeepFountain } from "./deep-observe";
|
|
10
|
+
export type { FountainDeepObserveOptions } from "./deep-observe";
|
|
11
|
+
export {
|
|
12
|
+
fountainDeepNormalizationHooks,
|
|
13
|
+
FOUNTAIN_SERVER_FIELDS,
|
|
14
|
+
FOUNTAIN_DEFAULTS,
|
|
15
|
+
} from "./deep-observe-hooks";
|
|
16
|
+
|
|
7
17
|
// Generated resources — Environment, Vault, Agent, and property types.
|
|
8
18
|
export * from "./generated/index";
|
|
9
19
|
|
package/src/plugin.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { FountainGenerator } from "./import/generator";
|
|
|
21
21
|
import { sitesToTemplateIR } from "./import/local-agents";
|
|
22
22
|
import { completions } from "./lsp/completions";
|
|
23
23
|
import { hover } from "./lsp/hover";
|
|
24
|
+
import { fountainDeepNormalizationHooks } from "./deep-observe-hooks";
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* fountain lexicon plugin.
|
|
@@ -171,6 +172,17 @@ export const fountainPlugin: LexiconPlugin = {
|
|
|
171
172
|
return describeResources(options);
|
|
172
173
|
},
|
|
173
174
|
|
|
175
|
+
// Dynamic, never static: `chant build` must not resolve the live transport
|
|
176
|
+
// just to synthesize a manifest. The hooks below are plain data and are
|
|
177
|
+
// imported statically, because core normalizes the *declared* tree with them
|
|
178
|
+
// whether or not a live read ever happens.
|
|
179
|
+
async observeResourcesDeep(options) {
|
|
180
|
+
const { observeResourcesDeepFountain } = await import("./deep-observe");
|
|
181
|
+
return observeResourcesDeepFountain(options);
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
deepNormalizationHooks: fountainDeepNormalizationHooks,
|
|
185
|
+
|
|
174
186
|
referenceCatalog: fountainReferenceCatalog,
|
|
175
187
|
|
|
176
188
|
completionProvider(ctx: CompletionContext) {
|