@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.
Files changed (25) hide show
  1. package/dist/__tests__/installer-cwd-pin-and-auth-verify.test.js +82 -0
  2. package/dist/index.js +44 -5
  3. package/package.json +1 -1
  4. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +5 -3
  5. package/payload/platform/plugins/docs/references/cross-account-authority.md +3 -1
  6. package/payload/platform/plugins/docs/references/memory-guide.md +1 -1
  7. package/payload/platform/plugins/memory/PLUGIN.md +4 -2
  8. package/payload/platform/plugins/memory/mcp/dist/index.js +57 -18
  9. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  10. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-owner-userid.d.ts +15 -0
  11. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-owner-userid.d.ts.map +1 -0
  12. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-owner-userid.js +29 -0
  13. package/payload/platform/plugins/memory/mcp/dist/lib/resolve-owner-userid.js.map +1 -0
  14. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/cross-account.test.js +24 -15
  15. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/cross-account.test.js.map +1 -1
  16. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/resolve-owner-userid.test.d.ts +2 -0
  17. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/resolve-owner-userid.test.d.ts.map +1 -0
  18. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/resolve-owner-userid.test.js +40 -0
  19. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/resolve-owner-userid.test.js.map +1 -0
  20. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts +11 -0
  21. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts.map +1 -1
  22. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js +13 -1
  23. package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js.map +1 -1
  24. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +2 -0
  25. 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
- // Write version marker so the running platform knows which create-maxy produced this deployment
2482
- const versionMarkerPath = join(configDir, `.${BRAND.hostname}-version`);
2483
- writeFileSync(versionMarkerPath, PKG_VERSION, "utf-8");
2484
- console.log(` [install] version-marker written path=${versionMarkerPath} version=${PKG_VERSION}`);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.412",
3
+ "version": "0.1.414",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -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:3e8c5b6a28d0ca8c9bcbbb802165d8b2fffcb1af2531353bda360e8b7eb964f6
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
- A few memory tools do **not** take a target. `profile-read`/`update`/`delete` and `session-compact` are keyed to the caller's own identity (the operator's user, and the live session) rather than only the account, so re-pointing the account alone would act on the wrong record; cross-account for these is a separate, later piece of work. `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.
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 ("what do you know about me") stay on your own account for now.
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
- A few memory tools do **not** take a target. `profile-read`/`update`/`delete` and `session-compact` are keyed to the caller's own identity (the operator's user, and the live session) rather than only the account, so re-pointing the account alone would act on the wrong record; cross-account for these is a separate, later piece of work. `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.
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 ("what do you know about me") stay on your own account for now.
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 is scoped **solely** by `accountId` 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.
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
- 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 a caller constant the parameter cannot re-scope:** `profile-read/update/delete` key on the operator's `userId`, and `session-compact` keys on the caller's live session cross-account support for these is deferred (it needs `userId`/session re-scoping, a separate design question). **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.
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
- }, async ({ category, key, value, source, mode, sessionId, profileFields, personFields, mergeSourceIds }) => {
2174
- const scoped = resolveToolAccount("profile-update", undefined);
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
- if (!userId) {
2180
- return { content: [{ type: "text", text: "profile-update requires an authenticated admin session with userId" }], isError: true };
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
- }, async ({ category, key }) => {
2217
- const scoped = resolveToolAccount("profile-delete", undefined);
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
- if (!userId) {
2223
- return { content: [{ type: "text", text: "profile-delete requires an authenticated admin session with userId" }], isError: true };
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
- }, async ({ detail }) => {
2253
- const scoped = resolveToolAccount("profile-read", undefined);
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
- if (!userId) {
2259
- return { content: [{ type: "text", text: "profile-read requires an authenticated admin session with userId" }], isError: true };
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
  };