@tpsdev-ai/flair 0.53.0 → 0.54.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1791 -15648
- package/dist/commands/agent.js +453 -0
- package/dist/commands/attention.js +121 -0
- package/dist/commands/backup.js +115 -0
- package/dist/commands/bootstrap.js +91 -0
- package/dist/commands/bridge.js +608 -0
- package/dist/commands/deploy.js +180 -0
- package/dist/commands/doctor.js +1665 -0
- package/dist/commands/export.js +110 -0
- package/dist/commands/federation.js +1575 -0
- package/dist/commands/fleet.js +73 -0
- package/dist/commands/grant.js +109 -0
- package/dist/commands/hook.js +193 -0
- package/dist/commands/idp.js +193 -0
- package/dist/commands/import.js +134 -0
- package/dist/commands/init.js +1203 -0
- package/dist/commands/inspect.js +45 -0
- package/dist/commands/keys.js +187 -0
- package/dist/commands/mcp.js +707 -0
- package/dist/commands/memory.js +501 -0
- package/dist/commands/migrate-harness-memory.js +270 -0
- package/dist/commands/orgevent.js +138 -0
- package/dist/commands/presence.js +76 -0
- package/dist/commands/principal.js +338 -0
- package/dist/commands/quality.js +1164 -0
- package/dist/commands/reembed.js +296 -0
- package/dist/commands/relationship.js +76 -0
- package/dist/commands/rem.js +1048 -0
- package/dist/commands/restore.js +130 -0
- package/dist/commands/search.js +244 -0
- package/dist/commands/service.js +315 -0
- package/dist/commands/session.js +184 -0
- package/dist/commands/soul.js +155 -0
- package/dist/commands/status.js +931 -0
- package/dist/commands/test.js +93 -0
- package/dist/commands/uninstall.js +143 -0
- package/dist/commands/upgrade.js +1628 -0
- package/dist/commands/workspace.js +114 -0
- package/dist/deploy.js +24 -0
- package/dist/engine-version.js +12 -4
- package/dist/fabric-npm-install.js +87 -0
- package/dist/fabric-upgrade.js +30 -15
- package/dist/federation-verify.js +498 -0
- package/dist/fleet-verify.js +144 -21
- package/dist/install/clients.js +167 -0
- package/dist/lib/auth-resolve.js +76 -1
- package/dist/lib/daemon-liveness.js +131 -2
- package/dist/lib/doctor-config-path.js +61 -0
- package/dist/lib/doctor-federation-driver.js +189 -0
- package/dist/lib/doctor-run.js +40 -0
- package/dist/lib/entity-vocab-cli.js +3 -3
- package/dist/lib/federation-pair-identity.js +47 -0
- package/dist/lib/launchd-repair.js +5 -4
- package/dist/lib/npm-registry.js +578 -0
- package/dist/lib/ops-api-bind.js +115 -0
- package/dist/lib/owned-pins.js +219 -0
- package/dist/lib/uninstall-purge.js +218 -0
- package/dist/rem/restore.js +8 -10
- package/dist/resources/AgentReadPosition.js +74 -0
- package/dist/resources/Federation.js +8 -2
- package/dist/resources/Memory.js +4 -3
- package/dist/resources/MemoryBootstrap.js +41 -25
- package/dist/resources/MemoryCandidate.js +5 -6
- package/dist/resources/OrgEventCatchup.js +126 -47
- package/dist/resources/agent-read-position-lib.js +83 -0
- package/dist/resources/agent-read-position.js +120 -0
- package/dist/resources/embeddings-boot.js +32 -0
- package/dist/resources/federation-peer-liveness.js +73 -0
- package/dist/resources/health.js +68 -19
- package/dist/resources/mcp-tools.js +48 -279
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +59 -18
- package/dist/resources/migrations/embedding-stamp.js +20 -1
- package/dist/resources/migrations/recheck.js +43 -0
- package/dist/resources/migrations/runner.js +6 -1
- package/dist/resources/migrations/stamp-outstanding.js +171 -0
- package/dist/resources/migrations/visibility-backfill.js +2 -2
- package/dist/resources/org-event-catchup-lib.js +47 -0
- package/dist/resources/record-owner-guard.js +1 -0
- package/dist/resources/tool-descriptors/index.js +669 -0
- package/dist/stamp-migration-verify.js +163 -0
- package/dist/stamp-outstanding.js +144 -0
- package/dist/version-check.js +29 -8
- package/docs/api-reference.md +4 -2
- package/docs/deploying-on-fabric.md +11 -10
- package/docs/deployment.md +3 -1
- package/docs/federation.md +19 -0
- package/docs/hosted-on-fabric.md +3 -3
- package/docs/quickstart.md +2 -1
- package/docs/releasing.md +20 -6
- package/docs/spoke-bringup.md +10 -5
- package/docs/standalone-local.md +3 -1
- package/docs/upgrade.md +25 -6
- package/package.json +4 -4
- package/schemas/agent.graphql +15 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ops-api-bind.ts — one decision for "is the Harper ops API bound to all
|
|
3
|
+
* interfaces?", shared by `flair doctor` and `flair status` (flair#670,
|
|
4
|
+
* unified in flair#852).
|
|
5
|
+
*
|
|
6
|
+
* The Harper ops API used to bind all interfaces unconditionally. `flair init`
|
|
7
|
+
* now defaults it to loopback + the domain socket, with an escape hatch
|
|
8
|
+
* (`--ops-bind` / `FLAIR_OPS_BIND`) for deployments that genuinely need remote
|
|
9
|
+
* ops access (flair#670). That narrowing is only useful if every health surface
|
|
10
|
+
* agrees on what the running install actually bound, and it is what doctor
|
|
11
|
+
* reads out of Harper's own config. The predicate is an allow-list: ONLY a
|
|
12
|
+
* loopback host narrows the bind. A bare numeric port, a wildcard host
|
|
13
|
+
* (`0.0.0.0:19925`, `[::]:19925`, …), an empty/unspecified host and an
|
|
14
|
+
* unparseable value are all reported as exposed — a `host:port` string is not
|
|
15
|
+
* evidence of narrowing unless the host is actually loopback.
|
|
16
|
+
*
|
|
17
|
+
* That allow-list is the flair#852 wildcard blind spot. The detector originally
|
|
18
|
+
* treated ANY `host:port` as narrowed, so `flair init --ops-bind 0.0.0.0`
|
|
19
|
+
* persisted `0.0.0.0:19925` and both `flair status` and `flair doctor` printed
|
|
20
|
+
* green while the ops API was reachable off-box.
|
|
21
|
+
*
|
|
22
|
+
* flair#852 was the two surfaces disagreeing. `flair doctor` flagged the bare
|
|
23
|
+
* port while `flair status` printed "✓ all checks passing" — a security-relevant
|
|
24
|
+
* exposure visible to one command and invisible to the other, so users shipped
|
|
25
|
+
* with the ops API reachable off-box. The fix is structural: the decision lives
|
|
26
|
+
* HERE, both commands call it, and status folds the finding into the same
|
|
27
|
+
* warning verdict that drives its green line.
|
|
28
|
+
*
|
|
29
|
+
* Pure: parsing Harper's config is the caller's job (doctor/status both read it
|
|
30
|
+
* with `readHarperConfig`); this module only decides.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Hosts that genuinely narrow the ops-API bind to loopback. This is an
|
|
34
|
+
* allow-list on purpose: ANYTHING not in it — a wildcard (`0.0.0.0`, `::`,
|
|
35
|
+
* `[::]`, `0:0:0:0:0:0:0:0`), an explicit routable host, an empty/unspecified
|
|
36
|
+
* host, or a value we cannot parse — is reported as exposed. The cost of a
|
|
37
|
+
* missed exposure (a user ships an ops API reachable off-box) is worse than the
|
|
38
|
+
* cost of warning about a bind we did not recognise.
|
|
39
|
+
*/
|
|
40
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
41
|
+
/**
|
|
42
|
+
* Best-effort host half of a persisted `operationsApi.network.port` value.
|
|
43
|
+
* Returns null when the value declares no host (a bare port) or the host is
|
|
44
|
+
* unparseable; the caller reports both as exposed.
|
|
45
|
+
*
|
|
46
|
+
* Handles the forms flair and Harper write:
|
|
47
|
+
* - `127.0.0.1:19925` → `127.0.0.1`
|
|
48
|
+
* - `[::1]:19925` → `::1` (bracketed IPv6, port stripped)
|
|
49
|
+
* - `[::]:19925` → `::`
|
|
50
|
+
* - `::1:19925` → `::1` (bare IPv6 with a trailing numeric port)
|
|
51
|
+
* - `::` → `::` (bare wildcard, nothing to strip)
|
|
52
|
+
* - `0:0:0:0:0:0:0:0` → `0:0:0:0:0:0:0` (still not loopback — flagged)
|
|
53
|
+
*
|
|
54
|
+
* Splits on the LAST colon so an IPv6 literal keeps its port, matching
|
|
55
|
+
* `harperPortValue`. A trailing all-digit segment is treated as the port; a
|
|
56
|
+
* bare IPv6 like `::` has no trailing port, so the whole value is the host.
|
|
57
|
+
*/
|
|
58
|
+
function parseBindHost(str) {
|
|
59
|
+
if (str.startsWith("[")) {
|
|
60
|
+
const close = str.indexOf("]");
|
|
61
|
+
if (close === -1)
|
|
62
|
+
return null; // malformed bracket form — unparseable
|
|
63
|
+
return str.slice(1, close);
|
|
64
|
+
}
|
|
65
|
+
const lastColon = str.lastIndexOf(":");
|
|
66
|
+
if (lastColon === -1)
|
|
67
|
+
return null; // bare port — no host to narrow on
|
|
68
|
+
const tail = str.slice(lastColon + 1);
|
|
69
|
+
return /^\d+$/.test(tail) ? str.slice(0, lastColon) : str;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Decide whether a persisted `operationsApi.network.port` value (read back from
|
|
73
|
+
* harper-config.yaml) indicates an ops-API bind reachable off-box.
|
|
74
|
+
*
|
|
75
|
+
* A bare port number/numeric string is Harper's all-interfaces default (the
|
|
76
|
+
* pre-flair#670 behavior, or an install that predates the fix and has not been
|
|
77
|
+
* re-`init`ed). A `host:port` string narrows the bind ONLY when the host is
|
|
78
|
+
* loopback — a wildcard (`0.0.0.0:19925`, `[::]:19925`, `::19925`) is still
|
|
79
|
+
* all-interfaces, and is flagged (flair#852). Empty/unspecified and unparseable
|
|
80
|
+
* values are flagged too.
|
|
81
|
+
*/
|
|
82
|
+
export function detectOpsApiAllInterfacesBind(portValue) {
|
|
83
|
+
if (portValue === undefined || portValue === null)
|
|
84
|
+
return { allInterfaces: false, boundHost: null };
|
|
85
|
+
const str = String(portValue).trim();
|
|
86
|
+
if (str === "")
|
|
87
|
+
return { allInterfaces: false, boundHost: null };
|
|
88
|
+
const host = parseBindHost(str);
|
|
89
|
+
if (host !== null && LOOPBACK_HOSTS.has(host.toLowerCase())) {
|
|
90
|
+
return { allInterfaces: false, boundHost: host };
|
|
91
|
+
}
|
|
92
|
+
return { allInterfaces: true, boundHost: null };
|
|
93
|
+
}
|
|
94
|
+
const OPS_API_BIND_REMEDY = "Single-host installs don't need this reachable off-box. Fix: flair init && flair restart " +
|
|
95
|
+
"(rebinds to loopback + domain socket; re-init reuses your existing admin password, so this is " +
|
|
96
|
+
"safe on a running install — pass --ops-bind for deliberate remote admin)";
|
|
97
|
+
/**
|
|
98
|
+
* The ops-API bind finding for a parsed harper-config, or null when the config
|
|
99
|
+
* declares no ops port (nothing to report). This is the single source of truth
|
|
100
|
+
* `flair doctor` and `flair status` both call, so they cannot disagree about
|
|
101
|
+
* the same instance.
|
|
102
|
+
*/
|
|
103
|
+
export function opsApiBindFinding(harperConfig) {
|
|
104
|
+
const portValue = harperConfig?.operationsApi?.network?.port;
|
|
105
|
+
if (portValue === undefined || portValue === null || String(portValue).trim() === "")
|
|
106
|
+
return null;
|
|
107
|
+
const { allInterfaces, boundHost } = detectOpsApiAllInterfacesBind(portValue);
|
|
108
|
+
return {
|
|
109
|
+
allInterfaces,
|
|
110
|
+
portValue,
|
|
111
|
+
boundHost,
|
|
112
|
+
message: `Ops API bound to all interfaces (${String(portValue)})`,
|
|
113
|
+
remedy: OPS_API_BIND_REMEDY,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* owned-pins.ts — flair#1485
|
|
3
|
+
*
|
|
4
|
+
* The single catalogue of files `flair init` / `flair hook install` write a
|
|
5
|
+
* pinned `@tpsdev-ai/flair-mcp@<version>` into. `flair upgrade` refreshes
|
|
6
|
+
* every entry that is already wired; `flair doctor` reads the same list to
|
|
7
|
+
* decide whether a pin is current.
|
|
8
|
+
*
|
|
9
|
+
* Two kinds:
|
|
10
|
+
* mcp-client — ALL_CLIENTS kind:"mcp" config paths
|
|
11
|
+
* session-start-hook — SUPPORTED_HARNESSES hook settings paths
|
|
12
|
+
*
|
|
13
|
+
* Adding a client to ALL_CLIENTS or a harness to SUPPORTED_HARNESSES adds
|
|
14
|
+
* it here automatically — no second list to forget on the next upgrade.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { ALL_CLIENTS, clientConfigPath, } from "../install/clients.js";
|
|
18
|
+
import { checkSessionStartHook, extractFlairMcpPin, isFlairHookCommand, readClientMcpBlock, } from "../doctor-client.js";
|
|
19
|
+
import { hookInstallHint, hookSettingsPath, repinSessionStartHook, SUPPORTED_HARNESSES, } from "../hook-install.js";
|
|
20
|
+
import { flairCliVersion, isResolvedVersion } from "./mcp-spec.js";
|
|
21
|
+
function withHome(homeDir, fn) {
|
|
22
|
+
const prev = process.env.HOME;
|
|
23
|
+
process.env.HOME = homeDir;
|
|
24
|
+
try {
|
|
25
|
+
return fn();
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
if (prev === undefined)
|
|
29
|
+
delete process.env.HOME;
|
|
30
|
+
else
|
|
31
|
+
process.env.HOME = prev;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function displayHomePath(homeDir, path) {
|
|
35
|
+
if (path.startsWith(homeDir))
|
|
36
|
+
return "~" + path.slice(homeDir.length);
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Every file Flair pins. Order: MCP clients (registry order), then hooks
|
|
41
|
+
* (harness registry order). Upgrade and doctor both iterate this.
|
|
42
|
+
*/
|
|
43
|
+
export function listOwnedPinTargets(homeDir) {
|
|
44
|
+
return withHome(homeDir, () => {
|
|
45
|
+
const targets = [];
|
|
46
|
+
for (const client of ALL_CLIENTS) {
|
|
47
|
+
if (client.kind !== "mcp")
|
|
48
|
+
continue;
|
|
49
|
+
const path = clientConfigPath(client.id);
|
|
50
|
+
targets.push({
|
|
51
|
+
kind: "mcp-client",
|
|
52
|
+
id: client.id,
|
|
53
|
+
path,
|
|
54
|
+
displayPath: displayHomePath(homeDir, path),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
for (const harness of SUPPORTED_HARNESSES) {
|
|
58
|
+
const path = hookSettingsPath(homeDir, harness);
|
|
59
|
+
targets.push({
|
|
60
|
+
kind: "session-start-hook",
|
|
61
|
+
id: harness,
|
|
62
|
+
path,
|
|
63
|
+
displayPath: displayHomePath(homeDir, path),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return targets;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function readFileText(path) {
|
|
70
|
+
try {
|
|
71
|
+
if (!existsSync(path))
|
|
72
|
+
return null;
|
|
73
|
+
return readFileSync(path, "utf-8");
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function readOwnedPin(target, homeDir) {
|
|
80
|
+
if (target.kind === "session-start-hook") {
|
|
81
|
+
const hook = checkSessionStartHook(homeDir, target.path);
|
|
82
|
+
const present = !!(hook.present && hook.command && isFlairHookCommand(hook.command));
|
|
83
|
+
return {
|
|
84
|
+
target,
|
|
85
|
+
present,
|
|
86
|
+
pin: present ? extractFlairMcpPin(hook.command ?? "") : null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const block = readClientMcpBlock(target.id, homeDir);
|
|
90
|
+
const text = readFileText(target.path) ?? "";
|
|
91
|
+
return {
|
|
92
|
+
target,
|
|
93
|
+
present: block.present,
|
|
94
|
+
pin: block.present ? extractFlairMcpPin(text) : null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function readOwnedPins(homeDir) {
|
|
98
|
+
return listOwnedPinTargets(homeDir).map((t) => readOwnedPin(t, homeDir));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Wired pins whose concrete version is not the installed CLI version.
|
|
102
|
+
* Unpinned / absent entries are not stale — they are a different check.
|
|
103
|
+
*/
|
|
104
|
+
export function staleOwnedPins(homeDir, expectedVersion = flairCliVersion()) {
|
|
105
|
+
if (!isResolvedVersion(expectedVersion))
|
|
106
|
+
return [];
|
|
107
|
+
return readOwnedPins(homeDir).filter((r) => r.present && r.pin !== null && r.pin !== expectedVersion);
|
|
108
|
+
}
|
|
109
|
+
export function staleSessionStartHookPins(homeDir, expectedVersion = flairCliVersion()) {
|
|
110
|
+
return staleOwnedPins(homeDir, expectedVersion).filter((r) => r.target.kind === "session-start-hook");
|
|
111
|
+
}
|
|
112
|
+
export function staleMcpClientPins(homeDir, expectedVersion = flairCliVersion()) {
|
|
113
|
+
return staleOwnedPins(homeDir, expectedVersion).filter((r) => r.target.kind === "mcp-client");
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* What `flair upgrade` prints from a refresh result.
|
|
117
|
+
*
|
|
118
|
+
* A failed `client.wire` is `action: "skip"` + `ok: false` (same shape as a
|
|
119
|
+
* fail-closed hook re-pin). Filtering on `action !== "skip"` alone dropped
|
|
120
|
+
* that failure, left the pin stale, and printed nothing (Bugbot on #1485).
|
|
121
|
+
* Failures always surface. MCP no-ops/updates still print; hook no-ops stay quiet.
|
|
122
|
+
*/
|
|
123
|
+
export function ownedPinRefreshShouldReport(r) {
|
|
124
|
+
if (!r.ok)
|
|
125
|
+
return true;
|
|
126
|
+
if (r.target.kind === "mcp-client")
|
|
127
|
+
return r.action !== "skip";
|
|
128
|
+
return r.action === "update";
|
|
129
|
+
}
|
|
130
|
+
export function staleHookRemedy(readings) {
|
|
131
|
+
const harnesses = readings
|
|
132
|
+
.filter((r) => r.target.kind === "session-start-hook")
|
|
133
|
+
.map((r) => r.target.id);
|
|
134
|
+
if (harnesses.length === 0)
|
|
135
|
+
return "flair hook install";
|
|
136
|
+
return [...new Set(harnesses)].map((h) => hookInstallHint(h)).join(" ; ");
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Refresh every already-wired owned pin to the running CLI's spec.
|
|
140
|
+
*
|
|
141
|
+
* MCP client pins need `agentId` (the wire functions rewrite the env block).
|
|
142
|
+
* SessionStart hooks do not — `repinSessionStartHook` reads the agent from
|
|
143
|
+
* the existing command. A missing agentId therefore skips MCP only; it must
|
|
144
|
+
* not skip hooks (that was the early-return hole in the inline upgrade path).
|
|
145
|
+
*
|
|
146
|
+
* NEVER adds a hook or wires a new client.
|
|
147
|
+
*/
|
|
148
|
+
export function refreshOwnedPins(opts) {
|
|
149
|
+
const { homeDir } = opts;
|
|
150
|
+
const flairUrl = opts.flairUrl ?? "http://127.0.0.1:9926";
|
|
151
|
+
const agentId = opts.agentId ?? null;
|
|
152
|
+
const targets = listOwnedPinTargets(homeDir);
|
|
153
|
+
const results = [];
|
|
154
|
+
return withHome(homeDir, () => {
|
|
155
|
+
for (const target of targets) {
|
|
156
|
+
if (target.kind === "session-start-hook") {
|
|
157
|
+
const harness = target.id;
|
|
158
|
+
const repin = repinSessionStartHook(homeDir, harness);
|
|
159
|
+
results.push({
|
|
160
|
+
target,
|
|
161
|
+
action: repin.action,
|
|
162
|
+
ok: repin.ok,
|
|
163
|
+
message: repin.message,
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const block = readClientMcpBlock(target.id, homeDir);
|
|
168
|
+
if (!block.present) {
|
|
169
|
+
results.push({
|
|
170
|
+
target,
|
|
171
|
+
action: "skip",
|
|
172
|
+
ok: true,
|
|
173
|
+
message: `${target.id}: not wired — skip`,
|
|
174
|
+
});
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!agentId) {
|
|
178
|
+
results.push({
|
|
179
|
+
target,
|
|
180
|
+
action: "skip",
|
|
181
|
+
ok: true,
|
|
182
|
+
message: `${target.id}: no agent id — skip MCP pin refresh`,
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const client = ALL_CLIENTS.find((c) => c.id === target.id);
|
|
187
|
+
if (!client) {
|
|
188
|
+
results.push({
|
|
189
|
+
target,
|
|
190
|
+
action: "skip",
|
|
191
|
+
ok: true,
|
|
192
|
+
message: `${target.id}: not in client registry — skip`,
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const env = {
|
|
197
|
+
FLAIR_AGENT_ID: agentId,
|
|
198
|
+
FLAIR_URL: flairUrl,
|
|
199
|
+
FLAIR_CLIENT: target.id,
|
|
200
|
+
};
|
|
201
|
+
const before = extractFlairMcpPin(readFileText(target.path) ?? "");
|
|
202
|
+
const wired = client.wire(env);
|
|
203
|
+
const after = extractFlairMcpPin(readFileText(target.path) ?? "");
|
|
204
|
+
// Failed write stays skip+ok:false (fail-closed, like hook re-pin).
|
|
205
|
+
// ownedPinRefreshShouldReport treats !ok as printable — do not recode
|
|
206
|
+
// this as a quiet skip.
|
|
207
|
+
const action = !wired.ok ? "skip"
|
|
208
|
+
: before !== after ? "update"
|
|
209
|
+
: "noop";
|
|
210
|
+
results.push({
|
|
211
|
+
target,
|
|
212
|
+
action,
|
|
213
|
+
ok: wired.ok,
|
|
214
|
+
message: wired.message,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return results;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uninstall-purge.ts — flair#853
|
|
3
|
+
*
|
|
4
|
+
* `flair uninstall --purge` used to delete only `~/.flair/data` and
|
|
5
|
+
* `~/.flair/keys`, then print "Flair fully purged". That left the admin
|
|
6
|
+
* password, backups/logs/upgrade-snapshots, the REM nightly shim, client
|
|
7
|
+
* MCP/hook wiring, and the systemd/launchd scheduler units — a secret and
|
|
8
|
+
* a job that can resurrect on reinstall.
|
|
9
|
+
*
|
|
10
|
+
* This module is the purge body. The CLI stops the Harper service, then
|
|
11
|
+
* calls `purgeFlairInstall`. Docs say purge removes everything including
|
|
12
|
+
* data and keys; this does that, plus secrets, schedulers, and client
|
|
13
|
+
* wiring. The npm package is an intentional leftover: this CLI cannot
|
|
14
|
+
* uninstall itself. The report always names leftovers instead of claiming
|
|
15
|
+
* "fully purged" when anything remains.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, rmSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { ALL_CLIENTS } from "../install/clients.js";
|
|
21
|
+
import { uninstallContinuityHooks, uninstallHook, harnessSupportsContinuity, hookBackupPath, hookSettingsPath, SUPPORTED_HARNESSES, } from "../hook-install.js";
|
|
22
|
+
import { disableScheduler as disableRemScheduler, LAUNCHD_LABEL as REM_LAUNCHD_LABEL, SYSTEMD_SERVICE_UNIT as REM_SYSTEMD_SERVICE, SYSTEMD_TIMER_UNIT as REM_SYSTEMD_TIMER, } from "../rem/scheduler.js";
|
|
23
|
+
import { disableScheduler as disableFederationScheduler, LAUNCHD_LABEL as FED_LAUNCHD_LABEL, SYSTEMD_SERVICE_UNIT as FED_SYSTEMD_SERVICE, SYSTEMD_TIMER_UNIT as FED_SYSTEMD_TIMER, } from "../federation/scheduler.js";
|
|
24
|
+
/** The published CLI package. Named here so the leftover line cannot drift
|
|
25
|
+
* from the remedy command. */
|
|
26
|
+
export const FLAIR_NPM_PACKAGE = "@tpsdev-ai/flair";
|
|
27
|
+
function resolveHome(homeDir) {
|
|
28
|
+
return homeDir ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir();
|
|
29
|
+
}
|
|
30
|
+
function withHome(homeDir, fn) {
|
|
31
|
+
const prev = process.env.HOME;
|
|
32
|
+
process.env.HOME = homeDir;
|
|
33
|
+
try {
|
|
34
|
+
return fn();
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
if (prev === undefined)
|
|
38
|
+
delete process.env.HOME;
|
|
39
|
+
else
|
|
40
|
+
process.env.HOME = prev;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function displayUnderHome(homeDir, absPath) {
|
|
44
|
+
if (absPath.startsWith(homeDir))
|
|
45
|
+
return "~" + absPath.slice(homeDir.length);
|
|
46
|
+
return absPath;
|
|
47
|
+
}
|
|
48
|
+
function rmPath(absPath, removed, leftovers, display) {
|
|
49
|
+
if (!existsSync(absPath))
|
|
50
|
+
return;
|
|
51
|
+
try {
|
|
52
|
+
rmSync(absPath, { recursive: true, force: true });
|
|
53
|
+
removed.push(display);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
57
|
+
leftovers.push({
|
|
58
|
+
path: display,
|
|
59
|
+
kind: "failed",
|
|
60
|
+
reason: `could not remove: ${reason}`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function schedulerPaths(homeDir, spec) {
|
|
65
|
+
return [
|
|
66
|
+
join(homeDir, ".flair", "bin", spec.shimName),
|
|
67
|
+
join(homeDir, "Library", "LaunchAgents", `${spec.launchdLabel}.plist`),
|
|
68
|
+
join(homeDir, ".config", "systemd", "user", spec.systemdTimer),
|
|
69
|
+
join(homeDir, ".config", "systemd", "user", spec.systemdService),
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
function disableAndSweepScheduler(homeDir, skipUnload, kind, removed, leftovers) {
|
|
73
|
+
const spec = kind === "rem"
|
|
74
|
+
? {
|
|
75
|
+
shimName: "flair-rem-nightly",
|
|
76
|
+
launchdLabel: REM_LAUNCHD_LABEL,
|
|
77
|
+
systemdTimer: REM_SYSTEMD_TIMER,
|
|
78
|
+
systemdService: REM_SYSTEMD_SERVICE,
|
|
79
|
+
disable: disableRemScheduler,
|
|
80
|
+
}
|
|
81
|
+
: {
|
|
82
|
+
shimName: "flair-federation-sync",
|
|
83
|
+
launchdLabel: FED_LAUNCHD_LABEL,
|
|
84
|
+
systemdTimer: FED_SYSTEMD_TIMER,
|
|
85
|
+
systemdService: FED_SYSTEMD_SERVICE,
|
|
86
|
+
disable: disableFederationScheduler,
|
|
87
|
+
};
|
|
88
|
+
const paths = schedulerPaths(homeDir, spec);
|
|
89
|
+
try {
|
|
90
|
+
spec.disable({
|
|
91
|
+
skipUnload,
|
|
92
|
+
removeShim: true,
|
|
93
|
+
shimPathOverride: paths[0],
|
|
94
|
+
launchdPlistOverride: paths[1],
|
|
95
|
+
systemdTimerOverride: paths[2],
|
|
96
|
+
systemdServiceOverride: paths[3],
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// File sweep below still runs — unload failure must not keep units on disk.
|
|
101
|
+
}
|
|
102
|
+
for (const abs of paths) {
|
|
103
|
+
rmPath(abs, removed, leftovers, displayUnderHome(homeDir, abs));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* `uninstallHook` / `uninstallContinuityHooks` copy the settings file to a
|
|
108
|
+
* sibling `.bak` whenever it exists — including on a no-op. Purge must not
|
|
109
|
+
* leave that copy (or a pre-existing `.bak` it just overwrote). flair#853
|
|
110
|
+
* Bugbot: only deleting the bak after an actual remove left
|
|
111
|
+
* `~/.claude/settings.json.bak` / `~/.codex/hooks.json.bak` behind.
|
|
112
|
+
*/
|
|
113
|
+
function sweepHookBackup(settingsPath, reportedBackup, homeDir, removed, leftovers) {
|
|
114
|
+
const candidates = new Set();
|
|
115
|
+
if (reportedBackup)
|
|
116
|
+
candidates.add(reportedBackup);
|
|
117
|
+
candidates.add(hookBackupPath(settingsPath));
|
|
118
|
+
for (const bak of candidates) {
|
|
119
|
+
rmPath(bak, removed, leftovers, displayUnderHome(homeDir, bak));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Remove Flair-owned state under `homeDir`: the `~/.flair` tree (data, keys,
|
|
124
|
+
* admin-pass, backups, logs, snapshots, shims), REM + federation scheduler
|
|
125
|
+
* units on both platforms, SessionStart/continuity hooks, and MCP/native
|
|
126
|
+
* client wiring. The npm package is listed as an intentional leftover
|
|
127
|
+
* unless `omitNpmLeftover` is set.
|
|
128
|
+
*/
|
|
129
|
+
export function purgeFlairInstall(opts = {}) {
|
|
130
|
+
const homeDir = resolveHome(opts.homeDir);
|
|
131
|
+
const skipUnload = !!opts.skipSchedulerUnload;
|
|
132
|
+
const removed = [];
|
|
133
|
+
const leftovers = [];
|
|
134
|
+
disableAndSweepScheduler(homeDir, skipUnload, "rem", removed, leftovers);
|
|
135
|
+
disableAndSweepScheduler(homeDir, skipUnload, "federation", removed, leftovers);
|
|
136
|
+
for (const harness of SUPPORTED_HARNESSES) {
|
|
137
|
+
const hook = uninstallHook({ homeDir, harness });
|
|
138
|
+
if (!hook.ok) {
|
|
139
|
+
leftovers.push({ path: hook.path, kind: "failed", reason: hook.message });
|
|
140
|
+
}
|
|
141
|
+
else if (hook.delta && hook.delta.action !== "noop") {
|
|
142
|
+
removed.push(displayUnderHome(homeDir, hook.path) + " (SessionStart hook)");
|
|
143
|
+
}
|
|
144
|
+
sweepHookBackup(hook.path, hook.backupPath, homeDir, removed, leftovers);
|
|
145
|
+
if (!harnessSupportsContinuity(harness))
|
|
146
|
+
continue;
|
|
147
|
+
const continuity = uninstallContinuityHooks({ homeDir, harness });
|
|
148
|
+
if (!continuity.ok) {
|
|
149
|
+
leftovers.push({ path: continuity.path, kind: "failed", reason: continuity.message });
|
|
150
|
+
}
|
|
151
|
+
else if (continuity.actions && (continuity.actions.PostToolUse === "remove" || continuity.actions.Stop === "remove")) {
|
|
152
|
+
removed.push(displayUnderHome(homeDir, continuity.path) + " (continuity hooks)");
|
|
153
|
+
}
|
|
154
|
+
sweepHookBackup(continuity.path, continuity.backupPath, homeDir, removed, leftovers);
|
|
155
|
+
}
|
|
156
|
+
// Pre-existing sibling backups (or a harness we skipped) must not survive.
|
|
157
|
+
for (const harness of SUPPORTED_HARNESSES) {
|
|
158
|
+
sweepHookBackup(hookSettingsPath(homeDir, harness), null, homeDir, removed, leftovers);
|
|
159
|
+
}
|
|
160
|
+
const unwireResults = withHome(homeDir, () => ALL_CLIENTS.map((c) => c.unwire()));
|
|
161
|
+
for (const u of unwireResults) {
|
|
162
|
+
if (!u.ok) {
|
|
163
|
+
leftovers.push({ path: u.message, kind: "failed", reason: u.message });
|
|
164
|
+
}
|
|
165
|
+
else if (u.removed) {
|
|
166
|
+
removed.push(u.message);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const flairDir = join(homeDir, ".flair");
|
|
170
|
+
rmPath(flairDir, removed, leftovers, "~/.flair");
|
|
171
|
+
if (existsSync(flairDir)) {
|
|
172
|
+
leftovers.push({
|
|
173
|
+
path: "~/.flair",
|
|
174
|
+
kind: "failed",
|
|
175
|
+
reason: "directory still present after purge",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (!opts.omitNpmLeftover) {
|
|
179
|
+
leftovers.push({
|
|
180
|
+
path: FLAIR_NPM_PACKAGE,
|
|
181
|
+
kind: "intentional",
|
|
182
|
+
reason: "this CLI cannot uninstall itself",
|
|
183
|
+
remedy: `npm uninstall -g ${FLAIR_NPM_PACKAGE}`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return { removed, leftovers };
|
|
187
|
+
}
|
|
188
|
+
export function formatPurgeReport(result) {
|
|
189
|
+
const lines = [];
|
|
190
|
+
for (const item of result.removed) {
|
|
191
|
+
lines.push(`✅ Removed: ${item}`);
|
|
192
|
+
}
|
|
193
|
+
const failed = result.leftovers.filter((l) => l.kind === "failed");
|
|
194
|
+
const intentional = result.leftovers.filter((l) => l.kind === "intentional");
|
|
195
|
+
for (const item of failed) {
|
|
196
|
+
lines.push(`⚠️ Left (failed): ${item.path} — ${item.reason}`);
|
|
197
|
+
}
|
|
198
|
+
if (failed.length === 0 && intentional.length === 0) {
|
|
199
|
+
lines.push("");
|
|
200
|
+
lines.push("🗑️ Flair fully purged");
|
|
201
|
+
return { lines, claimedFullyPurged: true };
|
|
202
|
+
}
|
|
203
|
+
lines.push("");
|
|
204
|
+
lines.push("🗑️ Flair purged (data, keys, secrets, schedulers, and client wiring)");
|
|
205
|
+
if (intentional.length > 0) {
|
|
206
|
+
lines.push("");
|
|
207
|
+
lines.push("Intentionally left:");
|
|
208
|
+
for (const item of intentional) {
|
|
209
|
+
lines.push(` • ${item.path} — ${item.reason}`);
|
|
210
|
+
if (item.remedy)
|
|
211
|
+
lines.push(` Remove with: ${item.remedy}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { lines, claimedFullyPurged: false };
|
|
215
|
+
}
|
|
216
|
+
export function purgeHadFailures(result) {
|
|
217
|
+
return result.leftovers.some((l) => l.kind === "failed");
|
|
218
|
+
}
|
package/dist/rem/restore.js
CHANGED
|
@@ -41,14 +41,12 @@ function asArray(raw) {
|
|
|
41
41
|
function soulWrite(opts) {
|
|
42
42
|
return opts.soulApiCall ?? opts.apiCall;
|
|
43
43
|
}
|
|
44
|
-
async function listAgentCandidates(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
get_attributes: ["id", "claim"],
|
|
51
|
-
}));
|
|
44
|
+
async function listAgentCandidates(opsSearch, agentId) {
|
|
45
|
+
if (!opsSearch)
|
|
46
|
+
return [];
|
|
47
|
+
return asArray(await opsSearch("MemoryCandidate", [
|
|
48
|
+
{ search_attribute: "agentId", search_type: "equals", search_value: agentId },
|
|
49
|
+
], ["id", "claim"]));
|
|
52
50
|
}
|
|
53
51
|
function parseJsonlSafe(text) {
|
|
54
52
|
if (!text.trim())
|
|
@@ -142,7 +140,7 @@ export async function applySnapshot(opts) {
|
|
|
142
140
|
try {
|
|
143
141
|
const currentMem = asArray(await opts.apiCall("GET", `/Memory?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
144
142
|
const currentSouls = asArray(await opts.apiCall("GET", `/Soul?agentId=${encodeURIComponent(opts.agentId)}`));
|
|
145
|
-
const currentCandidates = await listAgentCandidates(opts.
|
|
143
|
+
const currentCandidates = await listAgentCandidates(opts.opsSearch, opts.agentId);
|
|
146
144
|
result.deleted.memories = currentMem.length;
|
|
147
145
|
result.deleted.souls = currentSouls.length;
|
|
148
146
|
result.deleted.candidates = currentCandidates.length;
|
|
@@ -186,7 +184,7 @@ export async function applySnapshot(opts) {
|
|
|
186
184
|
// so a leftover row 403s Soul PUT after operator auth succeeds.
|
|
187
185
|
let currentCandidates = [];
|
|
188
186
|
try {
|
|
189
|
-
currentCandidates = await listAgentCandidates(opts.
|
|
187
|
+
currentCandidates = await listAgentCandidates(opts.opsSearch, opts.agentId);
|
|
190
188
|
}
|
|
191
189
|
catch (err) {
|
|
192
190
|
errors.push(`fetch-candidates: ${err?.message ?? String(err)}`);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentReadPosition.ts — HTTP surface for the per-agent read-position primitive.
|
|
3
|
+
*
|
|
4
|
+
* GET /AgentReadPosition/{agentId}?stream=org-event
|
|
5
|
+
* POST /AgentReadPosition/{agentId} { stream, position }
|
|
6
|
+
*
|
|
7
|
+
* Owner-scoped: an agent reads/advances only its own watermark. Admins and
|
|
8
|
+
* internal calls may act on any agent. The table itself is not @export.
|
|
9
|
+
*/
|
|
10
|
+
import { Resource } from "harper";
|
|
11
|
+
import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
|
|
12
|
+
import { ORG_EVENT_STREAM } from "./agent-read-position-lib.js";
|
|
13
|
+
import { advanceReadPosition, defaultReadPositionTable, getReadPosition, } from "./agent-read-position.js";
|
|
14
|
+
function pathAgentId(pathInfo, resource) {
|
|
15
|
+
return ((typeof pathInfo === "object" && pathInfo !== null ? pathInfo.id : null) ??
|
|
16
|
+
(typeof pathInfo === "string" ? pathInfo : null) ??
|
|
17
|
+
resource?.getId?.() ??
|
|
18
|
+
null);
|
|
19
|
+
}
|
|
20
|
+
function queryValue(pathInfo, name) {
|
|
21
|
+
if (typeof pathInfo !== "object" || pathInfo === null)
|
|
22
|
+
return null;
|
|
23
|
+
return pathInfo.conditions?.find((c) => c.attribute === name)?.value ?? null;
|
|
24
|
+
}
|
|
25
|
+
function denyOwner() {
|
|
26
|
+
return new Response(JSON.stringify({ error: "forbidden: can only read or advance your own watermark" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
27
|
+
}
|
|
28
|
+
function ownerDenied(auth, agentId) {
|
|
29
|
+
if (auth.kind === "anonymous")
|
|
30
|
+
return true;
|
|
31
|
+
if (auth.kind === "agent" && !auth.isAdmin && auth.agentId !== agentId)
|
|
32
|
+
return true;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
export class AgentReadPosition extends Resource {
|
|
36
|
+
async allowRead() {
|
|
37
|
+
return allowVerified(this.getContext?.());
|
|
38
|
+
}
|
|
39
|
+
async allowCreate() {
|
|
40
|
+
return allowVerified(this.getContext?.());
|
|
41
|
+
}
|
|
42
|
+
async get(pathInfo) {
|
|
43
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
44
|
+
const agentId = pathAgentId(pathInfo, this);
|
|
45
|
+
if (!agentId) {
|
|
46
|
+
return new Response(JSON.stringify({ error: "agentId required in path: GET /AgentReadPosition/{agentId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
47
|
+
}
|
|
48
|
+
if (ownerDenied(auth, agentId))
|
|
49
|
+
return denyOwner();
|
|
50
|
+
const stream = queryValue(pathInfo, "stream") || ORG_EVENT_STREAM;
|
|
51
|
+
const ctx = this.getContext?.();
|
|
52
|
+
const position = await getReadPosition(defaultReadPositionTable(), agentId, stream, ctx);
|
|
53
|
+
return { agentId, stream, position };
|
|
54
|
+
}
|
|
55
|
+
async post(content, pathInfo) {
|
|
56
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
57
|
+
const agentId = pathAgentId(pathInfo, this) ??
|
|
58
|
+
(content && typeof content === "object" ? content.agentId : null) ??
|
|
59
|
+
null;
|
|
60
|
+
if (!agentId) {
|
|
61
|
+
return new Response(JSON.stringify({ error: "agentId required in path: POST /AgentReadPosition/{agentId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
62
|
+
}
|
|
63
|
+
if (ownerDenied(auth, agentId))
|
|
64
|
+
return denyOwner();
|
|
65
|
+
const stream = (content && typeof content === "object" && content.stream) || ORG_EVENT_STREAM;
|
|
66
|
+
const position = content && typeof content === "object" ? String(content.position ?? "") : "";
|
|
67
|
+
if (!position) {
|
|
68
|
+
return new Response(JSON.stringify({ error: "position required to advance watermark" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
69
|
+
}
|
|
70
|
+
const ctx = this.getContext?.();
|
|
71
|
+
const result = await advanceReadPosition(defaultReadPositionTable(), agentId, stream, position, ctx);
|
|
72
|
+
return { agentId, stream, ...result };
|
|
73
|
+
}
|
|
74
|
+
}
|