@rubytech/create-maxy-code 0.1.412 → 0.1.414
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/__tests__/installer-cwd-pin-and-auth-verify.test.js +82 -0
- package/dist/index.js +44 -5
- package/package.json +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +5 -3
- package/payload/platform/plugins/docs/references/cross-account-authority.md +3 -1
- package/payload/platform/plugins/docs/references/memory-guide.md +1 -1
- package/payload/platform/plugins/memory/PLUGIN.md +4 -2
- package/payload/platform/plugins/memory/mcp/dist/index.js +57 -18
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/resolve-owner-userid.d.ts +15 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/resolve-owner-userid.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/resolve-owner-userid.js +29 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/resolve-owner-userid.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/cross-account.test.js +24 -15
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/cross-account.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/resolve-owner-userid.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/resolve-owner-userid.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/resolve-owner-userid.test.js +40 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/resolve-owner-userid.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts +11 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js +13 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js.map +1 -1
- package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +2 -0
- package/payload/server/server.js +36 -4
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Static-grep contracts for Task 1462 — the installer must survive being
|
|
2
|
+
// invoked from a working directory that deployPayload() later wipes.
|
|
3
|
+
//
|
|
4
|
+
// Three defects were compounding on the customer Pi upgrade:
|
|
5
|
+
// A. main() never pins the process CWD, so once deployPayload() rmSync's the
|
|
6
|
+
// operator's CWD (they ran `npx` from inside ~/.<brand>/platform), every
|
|
7
|
+
// subprocess inherits a deleted inode → JVM `getcwd() failed` and Bun
|
|
8
|
+
// ENOENT.
|
|
9
|
+
// B. resetNeo4jAuth returned the freshly-generated password even when the
|
|
10
|
+
// 15x cypher-shell verify loop never passed, so the app persisted a
|
|
11
|
+
// password Neo4j never actually accepted.
|
|
12
|
+
// C. the version marker was stamped mid-deployPayload, before plugin
|
|
13
|
+
// registration could abort, so an aborted upgrade reported success.
|
|
14
|
+
//
|
|
15
|
+
// Why static-grep, not behaviour: the touched paths spawn (systemctl, brew,
|
|
16
|
+
// launchctl, cypher-shell) and run the whole install on import — the same
|
|
17
|
+
// reason macos-darwin-branch.test.ts contracts resetNeo4jAuth by shape. This
|
|
18
|
+
// suite pins the code shape so a refactor that drops a fix fails loudly here.
|
|
19
|
+
import test from "node:test";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { dirname, resolve } from "node:path";
|
|
24
|
+
// dist/__tests__/installer-cwd-pin-and-auth-verify.test.js → ../../src/index.ts
|
|
25
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const INDEX_TS = resolve(here, "../../src/index.ts");
|
|
27
|
+
const SRC = readFileSync(INDEX_TS, "utf-8");
|
|
28
|
+
// --- Defect A: CWD pin ---
|
|
29
|
+
test("installer pins the process CWD to a never-wiped directory at startup", () => {
|
|
30
|
+
assert.match(SRC, /process\.chdir\(/, "startup must call process.chdir to escape a wipeable inherited CWD");
|
|
31
|
+
// The pin target is the operator HOME (never wiped — deployPayload only
|
|
32
|
+
// rmSync's subdirs of INSTALL_DIR), with a "/" fallback if the chdir throws.
|
|
33
|
+
assert.match(SRC, /process\.chdir\(process\.env\.HOME\s*\?\?\s*["']\/["']\)/, "CWD pin target must be process.env.HOME with a '/' fallback");
|
|
34
|
+
assert.match(SRC, /process\.chdir\(["']\/["']\)/, "a '/' fallback chdir must exist for when the HOME chdir throws");
|
|
35
|
+
});
|
|
36
|
+
test("installer logs the resolved pinned CWD", () => {
|
|
37
|
+
assert.match(SRC, /cwd-pinned to=/, "startup must emit a grep-able resolved-CWD line");
|
|
38
|
+
});
|
|
39
|
+
test("the CWD pin runs before deployPayload wipes the tree", () => {
|
|
40
|
+
const chdirAt = SRC.indexOf("process.chdir(");
|
|
41
|
+
// `deployPayload();` (with semicolon) is the call site — the definition is
|
|
42
|
+
// `function deployPayload(): void`, so the semicolon disambiguates.
|
|
43
|
+
const deployCallAt = SRC.indexOf("deployPayload();");
|
|
44
|
+
assert.ok(chdirAt !== -1 && deployCallAt !== -1, "both anchors must exist");
|
|
45
|
+
assert.ok(chdirAt < deployCallAt, "process.chdir must appear before the deployPayload() call so no rmSync runs under a live inherited CWD");
|
|
46
|
+
});
|
|
47
|
+
// --- Defect B: resetNeo4jAuth verifies before returning ---
|
|
48
|
+
test("resetNeo4jAuth records whether the post-reset auth check ever passed", () => {
|
|
49
|
+
assert.match(SRC, /\[neo4j-auth-reset\] verified=/, "resetNeo4jAuth must emit a verified=<bool> port=<n> post-condition line");
|
|
50
|
+
});
|
|
51
|
+
test("resetNeo4jAuth throws instead of returning an unverified password", () => {
|
|
52
|
+
// The old shape returned `password` unconditionally after the wait loop.
|
|
53
|
+
// The fix guards the return on a `verified` flag and throws otherwise.
|
|
54
|
+
const fn = SRC.slice(SRC.indexOf("function resetNeo4jAuth"), SRC.indexOf("function redactInstallLogs"));
|
|
55
|
+
assert.ok(fn.length > 0, "resetNeo4jAuth body must be locatable");
|
|
56
|
+
const gateIdx = fn.search(/if\s*\(\s*!verified\s*\)/);
|
|
57
|
+
assert.ok(gateIdx !== -1, "resetNeo4jAuth must branch on !verified");
|
|
58
|
+
assert.match(fn, /throw new Error\(/, "resetNeo4jAuth must throw when the reset never verified");
|
|
59
|
+
// The credential can only be returned on the verified path: the !verified
|
|
60
|
+
// throw-gate must sit BEFORE `return password`, so an unverified reset can
|
|
61
|
+
// never fall through to a return. A positional check, not a
|
|
62
|
+
// `}...return password` regex — the fixed code legitimately has the
|
|
63
|
+
// throw-block's own `}` immediately before the return.
|
|
64
|
+
const returnIdx = fn.lastIndexOf("return password");
|
|
65
|
+
assert.ok(returnIdx !== -1, "resetNeo4jAuth must still return the password on the verified path");
|
|
66
|
+
assert.ok(gateIdx < returnIdx, "the !verified throw-gate must precede `return password` so an unverified reset can never return a credential");
|
|
67
|
+
});
|
|
68
|
+
// --- Defect C: version marker gated on full completion ---
|
|
69
|
+
test("deployPayload no longer stamps the version marker mid-deploy", () => {
|
|
70
|
+
const dp = SRC.slice(SRC.indexOf("function deployPayload"), SRC.indexOf("function registerLocalAndExternalPlugins"));
|
|
71
|
+
assert.ok(dp.length > 0, "deployPayload body must be locatable");
|
|
72
|
+
assert.ok(!/hostname\}-version`\)[\s\S]*?writeFileSync\([^\n]*PKG_VERSION/.test(dp), "deployPayload must not write the version marker — it moved to the end of a fully-successful install");
|
|
73
|
+
});
|
|
74
|
+
test("version marker is written only after plugin registration completes", () => {
|
|
75
|
+
// Anchor on the call site (`...Plugins();` with semicolon), not the
|
|
76
|
+
// `function ...Plugins(): void` definition — the definition sits far above
|
|
77
|
+
// main() and would make this pass trivially.
|
|
78
|
+
const registerCallAt = SRC.indexOf("registerLocalAndExternalPlugins();");
|
|
79
|
+
const markerAt = SRC.indexOf("version-marker written");
|
|
80
|
+
assert.ok(registerCallAt !== -1 && markerAt !== -1, "both anchors must exist");
|
|
81
|
+
assert.ok(markerAt > registerCallAt, "the version-marker write must appear after the registerLocalAndExternalPlugins() call so an aborted run does not report success");
|
|
82
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -1338,16 +1338,30 @@ function resetNeo4jAuth(port = DEFAULT_NEO4J_PORT, dataDir = "/var/lib/neo4j", p
|
|
|
1338
1338
|
shell("systemctl", ["start", serviceName], { sudo: true });
|
|
1339
1339
|
}
|
|
1340
1340
|
console.log(" Waiting for Neo4j to start...");
|
|
1341
|
+
let verified = false;
|
|
1341
1342
|
for (let i = 0; i < 15; i++) {
|
|
1342
1343
|
const check = spawnSync("cypher-shell", [
|
|
1343
1344
|
"-u", "neo4j", "-p", password,
|
|
1344
1345
|
"-a", `bolt://localhost:${port}`,
|
|
1345
1346
|
"RETURN 1",
|
|
1346
1347
|
], { stdio: "pipe", timeout: 5000 });
|
|
1347
|
-
if (check.status === 0)
|
|
1348
|
+
if (check.status === 0) {
|
|
1349
|
+
verified = true;
|
|
1348
1350
|
break;
|
|
1351
|
+
}
|
|
1349
1352
|
spawnSync("sleep", ["2"]);
|
|
1350
1353
|
}
|
|
1354
|
+
// Post-condition: the new password only becomes authoritative once a
|
|
1355
|
+
// cypher-shell login actually succeeds. Returning an unverified password let
|
|
1356
|
+
// ensureNeo4jPassword persist a credential Neo4j never accepted — a hard
|
|
1357
|
+
// mismatch no restart can heal (Task 1462). The commonest cause of a
|
|
1358
|
+
// never-verifying reset is a corrupted set-initial-password from a deleted
|
|
1359
|
+
// CWD (JVM `getcwd() failed`); the CWD pin removes that, this throw stops the
|
|
1360
|
+
// installer writing a bad password if it still fails.
|
|
1361
|
+
logFile(`[neo4j-auth-reset] verified=${verified} port=${port}`);
|
|
1362
|
+
if (!verified) {
|
|
1363
|
+
throw new Error(`Neo4j auth reset did not verify on port ${port}: cypher-shell RETURN 1 failed on all 15 attempts. The new password was NOT confirmed and will not be persisted. Check the install log for a JVM \`getcwd() failed\` (deleted install CWD) or a Neo4j that never accepted auth.`);
|
|
1364
|
+
}
|
|
1351
1365
|
return password;
|
|
1352
1366
|
}
|
|
1353
1367
|
/**
|
|
@@ -2478,10 +2492,10 @@ function deployPayload() {
|
|
|
2478
2492
|
// Mirrors checkAdminAuthInvariant() in platform/lib/admins-write/src/index.ts;
|
|
2479
2493
|
// future divergence between the two should be caught by the test suite.
|
|
2480
2494
|
runInstallInvariantCheck(persistentUsersFile, join(INSTALL_DIR, "data", "accounts"));
|
|
2481
|
-
//
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2495
|
+
// The version marker is NOT written here. It is stamped at the very end of a
|
|
2496
|
+
// fully-successful install (after plugin registration and every throwing
|
|
2497
|
+
// step), so an aborted upgrade cannot leave a marker that reports success it
|
|
2498
|
+
// did not complete. Task 1462.
|
|
2485
2499
|
console.log(` Deployed to ${INSTALL_DIR}`);
|
|
2486
2500
|
}
|
|
2487
2501
|
// register the local + external Claude Code plugins after the
|
|
@@ -4983,6 +4997,22 @@ const PLATFORM_HEADER = `[create-maxy] platform=${PLATFORM} arch=${process.arch}
|
|
|
4983
4997
|
console.log(PLATFORM_HEADER);
|
|
4984
4998
|
console.log(`[create-maxy] log=${LOG_FILE} persist=${PERSIST_DIR}`);
|
|
4985
4999
|
initLogging();
|
|
5000
|
+
// Pin the process CWD before any step runs. npx inherits the operator's shell
|
|
5001
|
+
// CWD; if they invoked the upgrade from inside the install tree (e.g. `cd
|
|
5002
|
+
// ~/.<brand>/platform && npx ...`), deployPayload()'s rmSync deletes that live
|
|
5003
|
+
// CWD mid-run and every subprocess spawned afterward inherits a deleted inode
|
|
5004
|
+
// (JVM `getcwd() failed` during the Neo4j auth reset; Bun ENOENT during plugin
|
|
5005
|
+
// registration). HOME is never wiped — deployPayload only rmSync's subdirs of
|
|
5006
|
+
// INSTALL_DIR — so it is a safe anchor; fall back to "/" if the HOME chdir
|
|
5007
|
+
// throws. Task 1462.
|
|
5008
|
+
try {
|
|
5009
|
+
process.chdir(process.env.HOME ?? "/");
|
|
5010
|
+
}
|
|
5011
|
+
catch {
|
|
5012
|
+
process.chdir("/");
|
|
5013
|
+
}
|
|
5014
|
+
console.log(` [create-maxy] cwd-pinned to=${process.cwd()}`);
|
|
5015
|
+
logFile(`[create-maxy] cwd-pinned to=${process.cwd()}`);
|
|
4986
5016
|
console.log("================================================================");
|
|
4987
5017
|
console.log(` ${BRAND.productName} — ${BRAND.tagline}. (${BRAND.hostname} v${PKG_VERSION})`);
|
|
4988
5018
|
console.log("================================================================");
|
|
@@ -5066,6 +5096,15 @@ try {
|
|
|
5066
5096
|
// (the reboot-loop this defends against). A partial/failed install exits
|
|
5067
5097
|
// before this point and never writes the drop-in.
|
|
5068
5098
|
configureHardwareWatchdog();
|
|
5099
|
+
// Stamp the version marker as the last thing a fully-successful install does.
|
|
5100
|
+
// Every throwing step (plugin registration, service install, account setup)
|
|
5101
|
+
// runs before this, so an aborted upgrade exits via the catch below and never
|
|
5102
|
+
// writes it — the running platform never reports a version it did not finish
|
|
5103
|
+
// installing. platform/config is excluded from the Task 848 freeze above, so
|
|
5104
|
+
// it is still writable here. Task 1462.
|
|
5105
|
+
const versionMarkerPath = join(INSTALL_DIR, "platform/config", `.${BRAND.hostname}-version`);
|
|
5106
|
+
writeFileSync(versionMarkerPath, PKG_VERSION, "utf-8");
|
|
5107
|
+
console.log(` [install] version-marker written path=${versionMarkerPath} version=${PKG_VERSION}`);
|
|
5069
5108
|
console.log("");
|
|
5070
5109
|
console.log("================================================================");
|
|
5071
5110
|
console.log("");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: platform-architecture
|
|
3
3
|
description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
|
|
4
|
-
content-hash: sha256:
|
|
4
|
+
content-hash: sha256:9b227cf8ab845f23cb1899d5b2ac2581cfac3aae772715bf02d288ee93d8fbc4
|
|
5
5
|
brand: maxy-code
|
|
6
6
|
product-name: Maxy
|
|
7
7
|
---
|
|
@@ -532,7 +532,9 @@ The operator wants this for two surfaces: the account graph and the account file
|
|
|
532
532
|
|
|
533
533
|
The `memory` plugin gives the admin session access to a sub-account's knowledge graph. Its tools whose read or write is scoped solely by the account — search, lookup, write, update, edit, delete, edges, attachments, reports, conversation history, ingest, and graph maintenance — take an optional target. Omit it and the tool acts on the session's own account, exactly as before; pass a sub-account and a house-scoped admin session reads or writes that account's subgraph instead; any other session that names a target is refused.
|
|
534
534
|
|
|
535
|
-
|
|
535
|
+
`profile-read`/`update`/`delete` also take a target. They are keyed to a user as well as the account, so cross-account they cannot use the caller's own user (a house operator has no record inside a sub-account); instead they resolve the sub-account's owner — the single admin identity seeded with every account — and act on that owner's profile. A house-scoped session can therefore read or set a sub-account's profile (its owner's name, role, timezone, locale, and preferences); a target with no owner is refused rather than writing a stray record. Setting a sub-account owner's email or telephone this way is not available yet.
|
|
536
|
+
|
|
537
|
+
A few memory tools do **not** take a target. `session-compact` acts on the caller's own live session, which belongs to the account the operator is in, so pointing it at a sub-account has no meaning. `graph-prune-denylist-*` and `memory-ingest-extract` are not account-scoped at all (a shared deny-list and a temporary staging step), so a target would mean nothing. These stay on the session's own account.
|
|
536
538
|
|
|
537
539
|
## Filesystem
|
|
538
540
|
|
|
@@ -1612,7 +1614,7 @@ People entries (`:Person`) are deliberately excluded from this dual-summary syst
|
|
|
1612
1614
|
|
|
1613
1615
|
## Working across accounts
|
|
1614
1616
|
|
|
1615
|
-
If you run Maxy as the house account — the operator account that manages sub-accounts — you can point a memory action at one of your sub-accounts instead of your own. Name the account when you ask, for example "search Anneke's account for the roofing quote" or "add this note to the Jones account." Most memory actions honour this: search, read, write, edit, delete, reports, and conversation history. Profile actions ("
|
|
1617
|
+
If you run Maxy as the house account — the operator account that manages sub-accounts — you can point a memory action at one of your sub-accounts instead of your own. Name the account when you ask, for example "search Anneke's account for the roofing quote" or "add this note to the Jones account." Most memory actions honour this: search, read, write, edit, delete, reports, and conversation history. Profile actions honour it too — you can read and set a sub-account's profile (its owner's name, role, timezone, locale, and preferences) from the house account, for example "set the Jones account's name to Jane"; the profile is attributed to that sub-account's owner, so there is no orphan record and no PIN is needed. Setting a sub-account owner's email or telephone from the house account is not available yet. Compacting a conversation always acts on the session you are in, so it cannot be pointed at a sub-account.
|
|
1616
1618
|
|
|
1617
1619
|
Leave the account out and nothing changes — the action runs on your own account exactly as before. Only the house account can do this. A sub-account session that tries to reach another account is refused, and naming an account that does not exist is refused too. Sub-accounts stay fully isolated from each other.
|
|
1618
1620
|
|
|
@@ -10,7 +10,9 @@ The operator wants this for two surfaces: the account graph and the account file
|
|
|
10
10
|
|
|
11
11
|
The `memory` plugin gives the admin session access to a sub-account's knowledge graph. Its tools whose read or write is scoped solely by the account — search, lookup, write, update, edit, delete, edges, attachments, reports, conversation history, ingest, and graph maintenance — take an optional target. Omit it and the tool acts on the session's own account, exactly as before; pass a sub-account and a house-scoped admin session reads or writes that account's subgraph instead; any other session that names a target is refused.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
`profile-read`/`update`/`delete` also take a target. They are keyed to a user as well as the account, so cross-account they cannot use the caller's own user (a house operator has no record inside a sub-account); instead they resolve the sub-account's owner — the single admin identity seeded with every account — and act on that owner's profile. A house-scoped session can therefore read or set a sub-account's profile (its owner's name, role, timezone, locale, and preferences); a target with no owner is refused rather than writing a stray record. Setting a sub-account owner's email or telephone this way is not available yet.
|
|
14
|
+
|
|
15
|
+
A few memory tools do **not** take a target. `session-compact` acts on the caller's own live session, which belongs to the account the operator is in, so pointing it at a sub-account has no meaning. `graph-prune-denylist-*` and `memory-ingest-extract` are not account-scoped at all (a shared deny-list and a temporary staging step), so a target would mean nothing. These stay on the session's own account.
|
|
14
16
|
|
|
15
17
|
## Filesystem
|
|
16
18
|
|
|
@@ -158,7 +158,7 @@ People entries (`:Person`) are deliberately excluded from this dual-summary syst
|
|
|
158
158
|
|
|
159
159
|
## Working across accounts
|
|
160
160
|
|
|
161
|
-
If you run {{productName}} as the house account — the operator account that manages sub-accounts — you can point a memory action at one of your sub-accounts instead of your own. Name the account when you ask, for example "search Anneke's account for the roofing quote" or "add this note to the Jones account." Most memory actions honour this: search, read, write, edit, delete, reports, and conversation history. Profile actions ("
|
|
161
|
+
If you run {{productName}} as the house account — the operator account that manages sub-accounts — you can point a memory action at one of your sub-accounts instead of your own. Name the account when you ask, for example "search Anneke's account for the roofing quote" or "add this note to the Jones account." Most memory actions honour this: search, read, write, edit, delete, reports, and conversation history. Profile actions honour it too — you can read and set a sub-account's profile (its owner's name, role, timezone, locale, and preferences) from the house account, for example "set the Jones account's name to Jane"; the profile is attributed to that sub-account's owner, so there is no orphan record and no PIN is needed. Setting a sub-account owner's email or telephone from the house account is not available yet. Compacting a conversation always acts on the session you are in, so it cannot be pointed at a sub-account.
|
|
162
162
|
|
|
163
163
|
Leave the account out and nothing changes — the action runs on your own account exactly as before. Only the house account can do this. A sub-account session that tries to reach another account is refused, and naming an account that does not exist is refused too. Sub-accounts stay fully isolated from each other.
|
|
164
164
|
|
|
@@ -187,9 +187,11 @@ Writes targeting `:Person`, `:UserProfile`, `:AdminUser`, `:Organization`, `:Loc
|
|
|
187
187
|
|
|
188
188
|
## Cross-account access (house admin) — `targetAccountId`
|
|
189
189
|
|
|
190
|
-
The memory tools whose Neo4j read/write
|
|
190
|
+
The memory tools whose Neo4j read/write re-scopes on the resolved account accept an optional `targetAccountId`. A house-scoped `role:admin` session sets it to read or write a **sub-account's** subgraph instead of its own; omitting it is byte-identical to the pre-existing own-account behaviour. Only a house-scoped session may set it: a sub-account or public session that passes `targetAccountId` is rejected `cross-account-denied`, and a house session naming an account that does not exist on this install is rejected `cross-account-invalid-target`. Resolution runs through the shared `resolveEffectiveAccount` primitive — there is no house fallback and no local re-implementation. Each call that sets `targetAccountId` emits one `[xacct] op=call tool=<t> caller=<own8> target=<tgt8> houseScope=<bool> decision=<allow|deny> reason=<…>` line before the graph call, and the tool's own `[memory-<t>] [<accountId>]` line prints the resolved (target) id as a wrong-subgraph cross-check.
|
|
191
191
|
|
|
192
|
-
|
|
192
|
+
`profile-read/update/delete` also take `targetAccountId`. They key on `userId` as well as `accountId`, so cross-account they cannot use the caller's boot `USER_ID` (a house operator has no node in the sub-account subgraph). Instead they resolve the target sub-account's **owner** `AdminUser` — the single identity seeded per account (`role:"owner"`, one per account) — and attribute the read/write to it, emitting `[xacct] op=identity tool=profile-* target=<sub8> resolved-userId=<owner8>`. A target with no owner `AdminUser` is rejected loudly (never a wrong-identity write). `profile-update` bootstraps the owner's `UserProfile` on this path so a preference-only write lands. This covers preferences and every `profileFields` value (name via `givenName`/`familyName`, `role`, `timezone`, `locale`, `expertise`); no `Person` node or PIN is required. Setting `personFields` identity (email/telephone → the owner `Person`) cross-account is deferred and keeps loud-failing until then.
|
|
193
|
+
|
|
194
|
+
Tools that do **not** take `targetAccountId` fall into three groups. **Not account-scoped:** `image-fetch` fetches image bytes by URL; `graph-prune-denylist-add/remove/list` mutate a brand-global deny-list; `memory-ingest-extract` stages a temp file — none read or write a sub-account subgraph. **Bound to the caller's own live session:** `session-compact` compacts the caller's `SESSION_ID` Conversation, which belongs to the house account the operator sits in — re-scoping `accountId` to a sub-account matches nothing and "compact a sub-account's live session" has no referent. **Scheduled, not interactive:** the dream-cycle **cron** (`lib/dream-cycle/*`) takes its account from the scheduler context, not a tool argument; only the interactive `memory-dream-run` tool is house-targetable. These tools keep their own-account no-account guard.
|
|
193
195
|
|
|
194
196
|
## Graph Hygiene
|
|
195
197
|
|
|
@@ -41,6 +41,7 @@ import { profileRead } from "./tools/profile-read.js";
|
|
|
41
41
|
import { memoryBrainCaptureRecent } from "./tools/memory-brain-capture-recent.js";
|
|
42
42
|
import { profileUpdate } from "./tools/profile-update.js";
|
|
43
43
|
import { profileDelete } from "./tools/profile-delete.js";
|
|
44
|
+
import { resolveOwnerUserId } from "./lib/resolve-owner-userid.js";
|
|
44
45
|
import { graphPruneDenylistAdd } from "./tools/graph-prune-denylist-add.js";
|
|
45
46
|
import { graphPruneDenylistList } from "./tools/graph-prune-denylist-list.js";
|
|
46
47
|
import { graphPruneDenylistRemove } from "./tools/graph-prune-denylist-remove.js";
|
|
@@ -104,6 +105,37 @@ const resolveToolAccount = makeResolveToolAccount({
|
|
|
104
105
|
// Single source for the operator-facing targetAccountId description — the LLM
|
|
105
106
|
// reads this verbatim, so it must not drift across the 35 targetable tools.
|
|
106
107
|
const XACCT_DESC = "House-scoped admin only: operate on the named sub-account subgraph instead of your own. Only a house-scoped session may set this; a non-house session that sets it is rejected.";
|
|
108
|
+
// Boot account, captured before the tool handlers shadow `accountId` with the
|
|
109
|
+
// resolved (possibly cross-account) id. A resolved id != this is a cross-account
|
|
110
|
+
// call (the resolver only returns a target for a house-authorised session).
|
|
111
|
+
const BOOT_ACCOUNT_ID = accountId;
|
|
112
|
+
async function resolveProfileIdentity(toolName, crossAccount, resolvedAccountId, callerUserId) {
|
|
113
|
+
if (crossAccount) {
|
|
114
|
+
const owner = await resolveOwnerUserId(resolvedAccountId);
|
|
115
|
+
if (!owner) {
|
|
116
|
+
return {
|
|
117
|
+
reject: {
|
|
118
|
+
content: [{
|
|
119
|
+
type: "text",
|
|
120
|
+
text: `${toolName} failed: target account ${resolvedAccountId.slice(0, 8)}… has no owner AdminUser to attribute the profile to — its graph root is unprovisioned (see Task 1359 seedAccountGraphRoot).`,
|
|
121
|
+
}],
|
|
122
|
+
isError: true,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
process.stderr.write(`[xacct] op=identity tool=${toolName} target=${resolvedAccountId.slice(0, 8)} resolved-userId=${owner.slice(0, 8)}\n`);
|
|
127
|
+
return { userId: owner };
|
|
128
|
+
}
|
|
129
|
+
if (!callerUserId) {
|
|
130
|
+
return {
|
|
131
|
+
reject: {
|
|
132
|
+
content: [{ type: "text", text: `${toolName} requires an authenticated admin session with userId` }],
|
|
133
|
+
isError: true,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { userId: callerUserId };
|
|
138
|
+
}
|
|
107
139
|
// Load the markdown schema sidecar once at startup. Every memory-write call
|
|
108
140
|
// reads its required-property and synonym maps. If loading fails we throw —
|
|
109
141
|
// a memory server running with a broken schema is worse than one that fails
|
|
@@ -2170,18 +2202,20 @@ if (!readOnly) {
|
|
|
2170
2202
|
.describe("Operator-identity fields written to the OWNS-bound Person. Use canonical `telephone` (NOT `phone` — that is the schema synonym, not the canonical name). Tool throws if the AdminUser-OWNS-Person edge is missing rather than silently no-op."),
|
|
2171
2203
|
mergeSourceIds: z.array(z.string()).optional()
|
|
2172
2204
|
.describe("For mode 'merge': preferenceIds of sources to combine into this preference"),
|
|
2173
|
-
|
|
2174
|
-
|
|
2205
|
+
targetAccountId: z.string().optional().describe(XACCT_DESC),
|
|
2206
|
+
}, async ({ category, key, value, source, mode, sessionId, profileFields, personFields, mergeSourceIds, targetAccountId }) => {
|
|
2207
|
+
const scoped = resolveToolAccount("profile-update", targetAccountId);
|
|
2175
2208
|
if ("reject" in scoped)
|
|
2176
2209
|
return scoped.reject;
|
|
2177
2210
|
const accountId = scoped.accountId;
|
|
2211
|
+
const crossAccount = accountId !== BOOT_ACCOUNT_ID;
|
|
2178
2212
|
try {
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2213
|
+
const ident = await resolveProfileIdentity("profile-update", crossAccount, accountId, userId);
|
|
2214
|
+
if ("reject" in ident)
|
|
2215
|
+
return ident.reject;
|
|
2182
2216
|
const result = await profileUpdate({
|
|
2183
2217
|
accountId,
|
|
2184
|
-
userId,
|
|
2218
|
+
userId: ident.userId,
|
|
2185
2219
|
category,
|
|
2186
2220
|
key,
|
|
2187
2221
|
value,
|
|
@@ -2191,6 +2225,7 @@ if (!readOnly) {
|
|
|
2191
2225
|
profileFields: profileFields,
|
|
2192
2226
|
personFields,
|
|
2193
2227
|
mergeSourceIds,
|
|
2228
|
+
bootstrapProfile: crossAccount,
|
|
2194
2229
|
});
|
|
2195
2230
|
return {
|
|
2196
2231
|
content: [{
|
|
@@ -2213,16 +2248,18 @@ if (!readOnly) {
|
|
|
2213
2248
|
category: z.enum(["communication", "scheduling", "decision", "workflow", "content", "interaction"])
|
|
2214
2249
|
.describe("Preference category"),
|
|
2215
2250
|
key: z.string().describe("Preference key to delete"),
|
|
2216
|
-
|
|
2217
|
-
|
|
2251
|
+
targetAccountId: z.string().optional().describe(XACCT_DESC),
|
|
2252
|
+
}, async ({ category, key, targetAccountId }) => {
|
|
2253
|
+
const scoped = resolveToolAccount("profile-delete", targetAccountId);
|
|
2218
2254
|
if ("reject" in scoped)
|
|
2219
2255
|
return scoped.reject;
|
|
2220
2256
|
const accountId = scoped.accountId;
|
|
2257
|
+
const crossAccount = accountId !== BOOT_ACCOUNT_ID;
|
|
2221
2258
|
try {
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
const result = await profileDelete({ accountId, userId, category, key });
|
|
2259
|
+
const ident = await resolveProfileIdentity("profile-delete", crossAccount, accountId, userId);
|
|
2260
|
+
if ("reject" in ident)
|
|
2261
|
+
return ident.reject;
|
|
2262
|
+
const result = await profileDelete({ accountId, userId: ident.userId, category, key });
|
|
2226
2263
|
return {
|
|
2227
2264
|
content: [{
|
|
2228
2265
|
type: "text",
|
|
@@ -2249,16 +2286,18 @@ if (!readOnly) {
|
|
|
2249
2286
|
"Use detail=true when the owner asks 'what do you know about me?' or when reviewing memory accuracy.", {
|
|
2250
2287
|
detail: z.boolean().optional().describe("When true, returns per-preference confidence, source, evidence trail, and memory statistics. " +
|
|
2251
2288
|
"Default false returns a compact summary for system prompt injection."),
|
|
2252
|
-
|
|
2253
|
-
|
|
2289
|
+
targetAccountId: z.string().optional().describe(XACCT_DESC),
|
|
2290
|
+
}, async ({ detail, targetAccountId }) => {
|
|
2291
|
+
const scoped = resolveToolAccount("profile-read", targetAccountId);
|
|
2254
2292
|
if ("reject" in scoped)
|
|
2255
2293
|
return scoped.reject;
|
|
2256
2294
|
const accountId = scoped.accountId;
|
|
2295
|
+
const crossAccount = accountId !== BOOT_ACCOUNT_ID;
|
|
2257
2296
|
try {
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
const result = await profileRead({ accountId, userId, detail: detail ?? false });
|
|
2297
|
+
const ident = await resolveProfileIdentity("profile-read", crossAccount, accountId, userId);
|
|
2298
|
+
if ("reject" in ident)
|
|
2299
|
+
return ident.reject;
|
|
2300
|
+
const result = await profileRead({ accountId, userId: ident.userId, detail: detail ?? false });
|
|
2262
2301
|
return {
|
|
2263
2302
|
content: [{ type: "text", text: result.summary }],
|
|
2264
2303
|
};
|