@tpsdev-ai/flair 0.5.3 → 0.6.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/deploy.js ADDED
@@ -0,0 +1,161 @@
1
+ import { spawn } from "node:child_process";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createRequire } from "node:module";
6
+ // Files that must be present in a Flair package for deployment.
7
+ // Mirrors the `files` array in package.json — keep in sync.
8
+ export const REQUIRED_PACKAGE_FILES = [
9
+ "dist",
10
+ "schemas",
11
+ "ui",
12
+ "config.yaml",
13
+ ];
14
+ export function validateOptions(opts) {
15
+ const errors = [];
16
+ if (!opts.target) {
17
+ if (!opts.fabricOrg)
18
+ errors.push("--fabric-org required (or FABRIC_ORG env)");
19
+ if (!opts.fabricCluster)
20
+ errors.push("--fabric-cluster required (or FABRIC_CLUSTER env)");
21
+ }
22
+ const hasBasic = !!(opts.fabricUser && opts.fabricPassword);
23
+ const hasBearer = !!opts.fabricToken;
24
+ if (!hasBasic && !hasBearer) {
25
+ errors.push("credentials required: pass --fabric-user + --fabric-password " +
26
+ "(or FABRIC_USER / FABRIC_PASSWORD env), or --fabric-token " +
27
+ "(FABRIC_TOKEN env)");
28
+ }
29
+ return errors;
30
+ }
31
+ export function buildTargetUrl(opts) {
32
+ if (opts.target)
33
+ return opts.target;
34
+ return `https://${opts.fabricCluster}.${opts.fabricOrg}.harperfabric.com`;
35
+ }
36
+ export function resolvePackageRoot(override) {
37
+ if (override) {
38
+ const abs = resolve(override);
39
+ if (!existsSync(join(abs, "package.json"))) {
40
+ throw new Error(`No package.json at ${abs}`);
41
+ }
42
+ return abs;
43
+ }
44
+ // Walk up from this module's location — works when installed locally
45
+ // and when npx extracts the tarball to a tmpdir.
46
+ try {
47
+ const here = dirname(fileURLToPath(import.meta.url));
48
+ let dir = here;
49
+ for (let i = 0; i < 8; i++) {
50
+ const pkgPath = join(dir, "package.json");
51
+ if (existsSync(pkgPath)) {
52
+ const json = JSON.parse(readFileSync(pkgPath, "utf8"));
53
+ if (json.name === "@tpsdev-ai/flair")
54
+ return dir;
55
+ }
56
+ const parent = dirname(dir);
57
+ if (parent === dir)
58
+ break;
59
+ dir = parent;
60
+ }
61
+ }
62
+ catch {
63
+ /* fall through */
64
+ }
65
+ try {
66
+ const req = createRequire(import.meta.url);
67
+ return dirname(req.resolve("@tpsdev-ai/flair/package.json"));
68
+ }
69
+ catch {
70
+ throw new Error("Could not locate @tpsdev-ai/flair package root. Try --package-root.");
71
+ }
72
+ }
73
+ export function validatePackageLayout(packageRoot) {
74
+ const missing = [];
75
+ for (const f of REQUIRED_PACKAGE_FILES) {
76
+ if (!existsSync(join(packageRoot, f)))
77
+ missing.push(f);
78
+ }
79
+ if (missing.length) {
80
+ throw new Error(`Flair package at ${packageRoot} is missing required entries: ` +
81
+ missing.join(", "));
82
+ }
83
+ }
84
+ function resolveHarperBin(packageRoot) {
85
+ const local = join(packageRoot, "node_modules/@harperfast/harper/dist/bin/harper.js");
86
+ if (existsSync(local))
87
+ return local;
88
+ try {
89
+ const req = createRequire(join(packageRoot, "package.json"));
90
+ const mainPath = req.resolve("@harperfast/harper");
91
+ let dir = dirname(mainPath);
92
+ for (let i = 0; i < 6; i++) {
93
+ const candidate = join(dir, "dist/bin/harper.js");
94
+ if (existsSync(candidate))
95
+ return candidate;
96
+ const parent = dirname(dir);
97
+ if (parent === dir)
98
+ break;
99
+ dir = parent;
100
+ }
101
+ }
102
+ catch {
103
+ /* fall through */
104
+ }
105
+ throw new Error("Could not locate Harper CLI binary (@harperfast/harper). " +
106
+ "Flair deploy requires Harper to be installed alongside Flair.");
107
+ }
108
+ function spawnHarper(bin, args, cwd, env) {
109
+ return new Promise((resolveP, rejectP) => {
110
+ const p = spawn(process.execPath, [bin, ...args], {
111
+ cwd,
112
+ stdio: "inherit",
113
+ env,
114
+ });
115
+ p.on("error", rejectP);
116
+ p.on("exit", (code) => {
117
+ if (code === 0)
118
+ resolveP();
119
+ else
120
+ rejectP(new Error(`harper deploy exited with code ${code}`));
121
+ });
122
+ });
123
+ }
124
+ export async function deploy(opts) {
125
+ const errors = validateOptions(opts);
126
+ if (errors.length) {
127
+ throw new Error(errors.join("\n"));
128
+ }
129
+ const packageRoot = resolvePackageRoot(opts.packageRoot);
130
+ validatePackageLayout(packageRoot);
131
+ const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
132
+ const version = opts.version ?? pkg.version;
133
+ const project = opts.project ?? "flair";
134
+ const url = buildTargetUrl(opts);
135
+ if (opts.dryRun) {
136
+ return { url, project, version, packageRoot, dryRun: true };
137
+ }
138
+ if (opts.fabricToken && !(opts.fabricUser && opts.fabricPassword)) {
139
+ throw new Error("Bearer token auth (--fabric-token) is not yet supported — " +
140
+ "Harper's deploy_component CLI path only accepts Basic auth today. " +
141
+ "Pass --fabric-user + --fabric-password instead.");
142
+ }
143
+ const harperBin = resolveHarperBin(packageRoot);
144
+ const args = [
145
+ "deploy",
146
+ `target=${url}`,
147
+ `project=${project}`,
148
+ `restart=${opts.restart !== false}`,
149
+ `replicated=${opts.replicated !== false}`,
150
+ ];
151
+ // Credentials go via env, not argv, so they don't appear in `ps` output
152
+ // for the lifetime of the Harper child process. Harper's cliOperations
153
+ // reads CLI_TARGET_USERNAME / CLI_TARGET_PASSWORD as env fallbacks.
154
+ const childEnv = {
155
+ ...process.env,
156
+ CLI_TARGET_USERNAME: opts.fabricUser,
157
+ CLI_TARGET_PASSWORD: opts.fabricPassword,
158
+ };
159
+ await spawnHarper(harperBin, args, packageRoot, childEnv);
160
+ return { url, project, version, packageRoot, dryRun: false };
161
+ }
@@ -41,24 +41,34 @@ function formatMemory(m, supersedes) {
41
41
  }
42
42
  export class BootstrapMemories extends Resource {
43
43
  async post(data, _context) {
44
- const { agentId, currentTask, maxTokens = 4000, includeSoul = true, since, channel, // e.g., "discord", "tps-mail", "claude-code"
44
+ const { agentId: bodyAgentId, currentTask, maxTokens = 4000, includeSoul = true, since, channel, // e.g., "discord", "tps-mail", "claude-code"
45
45
  surface, // e.g., "tps-build", "tps-review", "cli-session"
46
46
  subjects, // e.g., ["flair", "auth"] — entities to preload context for
47
47
  } = data || {};
48
- if (!agentId) {
48
+ // Authenticated identity lives on getContext().request — `this.request` is
49
+ // NOT populated on Harper v5 Resources. Reading it returned undefined and
50
+ // the scope check was silently bypassed, letting a non-admin agent read
51
+ // another agent's soul + memories by passing the victim's id in the body.
52
+ const ctx = this.getContext?.();
53
+ const request = ctx?.request ?? ctx;
54
+ const authenticatedAgent = request?.tpsAgent ?? request?.headers?.get?.("x-tps-agent");
55
+ const callerIsAdmin = request?.tpsAgentIsAdmin === true;
56
+ if (!bodyAgentId && !authenticatedAgent) {
49
57
  return new Response(JSON.stringify({ error: "agentId required" }), {
50
58
  status: 400,
51
59
  headers: { "Content-Type": "application/json" },
52
60
  });
53
61
  }
54
- // Defense-in-depth: agentId must match authenticated agent
55
- const authenticatedAgent = this.request?.headers?.get?.("x-tps-agent");
56
- const callerIsAdmin = this.request?.tpsAgentIsAdmin === true;
57
- if (authenticatedAgent && !callerIsAdmin && agentId !== authenticatedAgent) {
62
+ if (authenticatedAgent && !callerIsAdmin && bodyAgentId && bodyAgentId !== authenticatedAgent) {
58
63
  return new Response(JSON.stringify({
59
64
  error: "forbidden: agentId must match authenticated agent",
60
65
  }), { status: 403, headers: { "Content-Type": "application/json" } });
61
66
  }
67
+ // Pin scope to the authenticated agent for non-admins; admins can bootstrap
68
+ // any agentId (needed for setup scripts and UI impersonation flows).
69
+ const agentId = (authenticatedAgent && !callerIsAdmin)
70
+ ? authenticatedAgent
71
+ : bodyAgentId;
62
72
  const sections = {
63
73
  soul: [],
64
74
  skills: [],
@@ -57,13 +57,22 @@ function evaluate(record, now, olderThanMs) {
57
57
  }
58
58
  export class ConsolidateMemories extends Resource {
59
59
  async post(data) {
60
- const { agentId, scope = "persistent", olderThan = "30d", limit = 20 } = data || {};
61
- if (!agentId)
60
+ const { agentId: bodyAgentId, scope = "persistent", olderThan = "30d", limit = 20 } = data || {};
61
+ // See SemanticSearch / MemoryBootstrap — `this.request` isn't populated on
62
+ // Harper v5 Resources, so the prior actorId check was silently bypassed
63
+ // and bob could enumerate alice's consolidation candidates (her memory records).
64
+ const ctx = this.getContext?.();
65
+ const request = ctx?.request ?? ctx;
66
+ const actorId = request?.tpsAgent;
67
+ const callerIsAdmin = request?.tpsAgentIsAdmin === true
68
+ || (actorId ? await isAdmin(actorId) : false);
69
+ if (!bodyAgentId && !actorId) {
62
70
  return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
63
- const actorId = this.request?.tpsAgent;
64
- if (actorId && actorId !== agentId && !(await isAdmin(actorId))) {
71
+ }
72
+ if (actorId && !callerIsAdmin && bodyAgentId && bodyAgentId !== actorId) {
65
73
  return new Response(JSON.stringify({ error: "forbidden: can only consolidate own memories" }), { status: 403 });
66
74
  }
75
+ const agentId = (actorId && !callerIsAdmin) ? actorId : bodyAgentId;
67
76
  const olderThanMs = parseDuration(olderThan);
68
77
  const now = Date.now();
69
78
  const candidates = [];
@@ -30,14 +30,23 @@ const FOCUS_PROMPTS = {
30
30
  };
31
31
  export class ReflectMemories extends Resource {
32
32
  async post(data) {
33
- const { agentId, scope = "recent", since, maxMemories = 50, focus = "lessons_learned", tag, } = data || {};
34
- if (!agentId)
33
+ const { agentId: bodyAgentId, scope = "recent", since, maxMemories = 50, focus = "lessons_learned", tag, } = data || {};
34
+ // Authenticated identity comes from getContext().request, not this.request
35
+ // (see SemanticSearch / MemoryBootstrap for the same bug class). The prior
36
+ // check was silently bypassed — bob could reflect on alice's memories and
37
+ // mutate alice's records via the lastReflected patchRecordSilent below.
38
+ const ctx = this.getContext?.();
39
+ const request = ctx?.request ?? ctx;
40
+ const actorId = request?.tpsAgent;
41
+ const callerIsAdmin = request?.tpsAgentIsAdmin === true
42
+ || (actorId ? await isAdmin(actorId) : false);
43
+ if (!bodyAgentId && !actorId) {
35
44
  return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
36
- // Auth: agent can only reflect on own memories unless admin
37
- const actorId = this.request?.tpsAgent;
38
- if (actorId && actorId !== agentId && !(await isAdmin(actorId))) {
45
+ }
46
+ if (actorId && !callerIsAdmin && bodyAgentId && bodyAgentId !== actorId) {
39
47
  return new Response(JSON.stringify({ error: "forbidden: can only reflect on own memories" }), { status: 403 });
40
48
  }
49
+ const agentId = (actorId && !callerIsAdmin) ? actorId : bodyAgentId;
41
50
  const sinceDate = since ? new Date(since) : new Date(Date.now() - 24 * 3600_000);
42
51
  const memories = [];
43
52
  for await (const record of databases.flair.Memory.search()) {
@@ -0,0 +1,19 @@
1
+ import { Resource } from "@harperfast/harper";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, resolve } from "node:path";
5
+ const HERE = dirname(fileURLToPath(import.meta.url));
6
+ const HTML_PATH = resolve(HERE, "..", "..", "ui", "observation-center.html");
7
+ const HTML = readFileSync(HTML_PATH, "utf8");
8
+ /**
9
+ * GET /ObservationCenter — serves the read-only dashboard shell.
10
+ * The HTML handles its own Basic-auth prompt and polls authenticated JSON endpoints.
11
+ */
12
+ export class ObservationCenter extends Resource {
13
+ async get() {
14
+ return new Response(HTML, {
15
+ status: 200,
16
+ headers: { "content-type": "text/html; charset=utf-8" },
17
+ });
18
+ }
19
+ }
@@ -46,13 +46,20 @@ function distanceToSimilarity(distance) {
46
46
  const CANDIDATE_MULTIPLIER = 5;
47
47
  export class SemanticSearch extends Resource {
48
48
  async post(data) {
49
- const { agentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since, asOf } = data || {};
50
- // Rate limiting use authenticated agent ID from request context, not client-supplied body
51
- const rateLimitAgent = this.request?.headers?.get?.("x-tps-agent")
52
- ?? this.request?.tpsAgent;
53
- if (rateLimitAgent) {
49
+ const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since, asOf } = data || {};
50
+ // Authenticated identity lives on the Harper Resource context (getContext().request).
51
+ // `this.request` is NOT populated on Harper v5 Resources — prior reads here
52
+ // silently returned undefined and the defense-in-depth scope check below
53
+ // was bypassed, letting a non-admin agent read another agent's memories
54
+ // by putting the victim's id in the body.
55
+ const ctx = this.getContext?.();
56
+ const request = ctx?.request ?? ctx;
57
+ const authenticatedAgent = request?.tpsAgent ?? request?.headers?.get?.("x-tps-agent");
58
+ const callerIsAdmin = request?.tpsAgentIsAdmin === true;
59
+ // Rate limiting — use authenticated agent ID, not client-supplied body
60
+ if (authenticatedAgent) {
54
61
  const bucket = q && !queryEmbedding ? "embedding" : "general";
55
- const rl = checkRateLimit(rateLimitAgent, bucket);
62
+ const rl = checkRateLimit(authenticatedAgent, bucket);
56
63
  if (!rl.allowed)
57
64
  return rateLimitResponse(rl.retryAfterMs, "search");
58
65
  }
@@ -61,15 +68,19 @@ export class SemanticSearch extends Resource {
61
68
  : subject
62
69
  ? new Set([subject.toLowerCase()])
63
70
  : null;
64
- // Defense-in-depth: verify agentId matches authenticated agent.
65
- const authenticatedAgent = this.request?.headers?.get?.("x-tps-agent");
66
- const callerIsAdmin = this.request?.tpsAgentIsAdmin === true;
67
- if (authenticatedAgent && !callerIsAdmin && agentId && agentId !== authenticatedAgent) {
71
+ // Enforce agentId = authenticated agent for non-admins. A mismatched body
72
+ // agentId is a cross-agent read attempt — reject outright. Admins can query
73
+ // any agentId (needed for bootstrap / consolidation scripts).
74
+ if (authenticatedAgent && !callerIsAdmin && bodyAgentId && bodyAgentId !== authenticatedAgent) {
68
75
  return new Response(JSON.stringify({
69
76
  error: "forbidden: agentId must match authenticated agent",
70
77
  }), { status: 403, headers: { "Content-Type": "application/json" } });
71
78
  }
72
- // Determine searchable agent IDs (own + granted)
79
+ // Scope search to the authenticated agent (own + granted). For admins or
80
+ // unauthenticated internal calls, honor the body-supplied agentId.
81
+ const agentId = (authenticatedAgent && !callerIsAdmin)
82
+ ? authenticatedAgent
83
+ : bodyAgentId;
73
84
  const searchAgentIds = new Set();
74
85
  if (agentId)
75
86
  searchAgentIds.add(agentId);
@@ -150,6 +150,12 @@ server.http(async (request, nextLayer) => {
150
150
  request.headers.set("x-tps-agent", "admin");
151
151
  if (request.headers.asObject)
152
152
  request.headers.asObject["x-tps-agent"] = "admin";
153
+ // Basic admin auth IS admin — resource-level checks (SemanticSearch,
154
+ // MemoryBootstrap, MemoryReflect, MemoryConsolidate) gate cross-agent
155
+ // access on this flag. Prior to 0.5.5 the check was a no-op so this
156
+ // was never needed; now it must be set.
157
+ request.tpsAgent = "admin";
158
+ request.tpsAgentIsAdmin = true;
153
159
  return nextLayer(request);
154
160
  }
155
161
  }