@rubytech/create-maxy-code 0.1.405 → 0.1.406

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 (29) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/scheduling/PLUGIN.md +2 -0
  3. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/agent-dispatch-manager-grounding.test.d.ts +2 -0
  4. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/agent-dispatch-manager-grounding.test.d.ts.map +1 -0
  5. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/agent-dispatch-manager-grounding.test.js +19 -0
  6. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/agent-dispatch-manager-grounding.test.js.map +1 -0
  7. package/payload/platform/plugins/whatsapp/mcp/dist/__tests__/deliverability-grounding.test.d.ts +2 -0
  8. package/payload/platform/plugins/whatsapp/mcp/dist/__tests__/deliverability-grounding.test.d.ts.map +1 -0
  9. package/payload/platform/plugins/whatsapp/mcp/dist/__tests__/deliverability-grounding.test.js +33 -0
  10. package/payload/platform/plugins/whatsapp/mcp/dist/__tests__/deliverability-grounding.test.js.map +1 -0
  11. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +2 -0
  12. package/payload/platform/plugins/whatsapp/skills/manage-whatsapp-config/SKILL.md +5 -1
  13. package/payload/platform/services/claude-session-manager/dist/claude-host-creds.d.ts +8 -7
  14. package/payload/platform/services/claude-session-manager/dist/claude-host-creds.d.ts.map +1 -1
  15. package/payload/platform/services/claude-session-manager/dist/claude-host-creds.js +63 -40
  16. package/payload/platform/services/claude-session-manager/dist/claude-host-creds.js.map +1 -1
  17. package/payload/platform/services/claude-session-manager/dist/claude-keychain-write.d.ts +40 -0
  18. package/payload/platform/services/claude-session-manager/dist/claude-keychain-write.d.ts.map +1 -0
  19. package/payload/platform/services/claude-session-manager/dist/claude-keychain-write.js +159 -0
  20. package/payload/platform/services/claude-session-manager/dist/claude-keychain-write.js.map +1 -0
  21. package/payload/platform/services/claude-session-manager/dist/connector-availability.d.ts +36 -0
  22. package/payload/platform/services/claude-session-manager/dist/connector-availability.d.ts.map +1 -0
  23. package/payload/platform/services/claude-session-manager/dist/connector-availability.js +76 -0
  24. package/payload/platform/services/claude-session-manager/dist/connector-availability.js.map +1 -0
  25. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +2 -1
  26. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +2 -2
  28. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  29. package/payload/server/server.js +80 -63
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.405",
3
+ "version": "0.1.406",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -129,6 +129,8 @@ An `action` invokes one pre-encoded MCP tool with no reasoning. When a due event
129
129
 
130
130
  An event carries **either** an `action` **or** an `agentDispatch`, never both — setting one clears the other. Agent dispatch is stored as `agentChannel`, `agentDestination`, `agentPrompt`. Use `schedule-update` with `agentDispatch` to modify or `agentDispatch: null` to clear. On WhatsApp, a destination bound as an account-manager scopes the spawned session to its sub-account; on Telegram the session runs on the house account.
131
131
 
132
+ A WhatsApp `agentDispatch` destination may therefore be an account manager, not only an admin phone — this is the supported way to send a manager a proactive or scheduled WhatsApp message. The reply is delivered over the house's paired line regardless of which sub-account the session is scoped to; socket ownership never restricts which number the dispatch can reach.
133
+
132
134
  Dispatch results are recorded on the Event node (`lastDispatchResult`, `lastDispatchAt`) exactly as for tool actions. Every heartbeat tick also audits stored agent dispatches and logs `[schedule-audit] op=stale-dispatch` for any whose destination has stopped being an admin or whose bound sub-account is gone, so a binding that rots between scheduling and firing is surfaced before it mis-dispatches.
133
135
 
134
136
  ## Reconciling public bookings (D1 → graph)
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=agent-dispatch-manager-grounding.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-dispatch-manager-grounding.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/agent-dispatch-manager-grounding.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,19 @@
1
+ // Task 1435 — content-presence gate for the scheduling side of the WhatsApp
2
+ // deliverability grounding. `isValidAgentDestination` accepts an account
3
+ // manager (adminPhones OR accountManagers), so scheduled dispatch is the
4
+ // supported way to reach a manager. PLUGIN.md must say so plainly, and deliver-
5
+ // over-the-house-line must be stated, so the agent cites it instead of inventing
6
+ // a line-ownership restriction. Mirrors time-resolve-description.test.ts.
7
+ import { describe, it, expect } from "vitest";
8
+ import { readFileSync } from "fs";
9
+ import { resolve } from "path";
10
+ const PLUGIN = readFileSync(resolve(__dirname, "../../../PLUGIN.md"), "utf8");
11
+ describe("agent-dispatch account-manager grounding", () => {
12
+ it("PLUGIN.md states a WhatsApp dispatch destination may be an account manager", () => {
13
+ expect(PLUGIN).toContain("A WhatsApp `agentDispatch` destination may therefore be an account manager");
14
+ });
15
+ it("PLUGIN.md states delivery is over the house line regardless of scope", () => {
16
+ expect(PLUGIN).toContain("socket ownership never restricts which number the dispatch can reach");
17
+ });
18
+ });
19
+ //# sourceMappingURL=agent-dispatch-manager-grounding.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-dispatch-manager-grounding.test.js","sourceRoot":"","sources":["../../src/__tests__/agent-dispatch-manager-grounding.test.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,yEAAyE;AACzE,yEAAyE;AACzE,gFAAgF;AAChF,iFAAiF;AACjF,0EAA0E;AAC1E,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,IAAI,CAAC;AAClC,OAAO,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAE/B,MAAM,MAAM,GAAG,YAAY,CAAC,OAAO,CAAC,SAAS,EAAE,oBAAoB,CAAC,EAAE,MAAM,CAAC,CAAC;AAE9E,QAAQ,CAAC,0CAA0C,EAAE,GAAG,EAAE;IACxD,EAAE,CAAC,4EAA4E,EAAE,GAAG,EAAE;QACpF,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CACtB,4EAA4E,CAC7E,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,sEAAsE,EAAE,GAAG,EAAE;QAC9E,MAAM,CAAC,MAAM,CAAC,CAAC,SAAS,CACtB,sEAAsE,CACvE,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=deliverability-grounding.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deliverability-grounding.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/deliverability-grounding.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,33 @@
1
+ // Task 1435 — content-presence gate for the WhatsApp deliverability grounding.
2
+ //
3
+ // The agent confabulated a false constraint ("the single line is pointed at
4
+ // the wrong account so it can't message this manager") because the skill named
5
+ // only the `whatsapp-send-admin` admin-phones-only limit and never pointed at
6
+ // the scheduled-dispatch path. The routing code is correct (1377/1382/1383/1385);
7
+ // this gate keeps the *grounding text* that removes the confabulation's foothold
8
+ // from silently regressing. Mirrors scheduling's time-resolve-description.test.ts.
9
+ import { describe, it, expect } from "vitest";
10
+ import { readFileSync } from "fs";
11
+ import { resolve } from "path";
12
+ const SKILL = readFileSync(resolve(__dirname, "../../../skills/manage-whatsapp-config/SKILL.md"), "utf8");
13
+ const REFERENCE = readFileSync(resolve(__dirname, "../../../references/channels-whatsapp.md"), "utf8");
14
+ describe("WhatsApp deliverability grounding", () => {
15
+ it("the config skill states the no-deliverability-restriction fact", () => {
16
+ expect(SKILL).toContain("never restricts which numbers a message can reach");
17
+ expect(SKILL).toContain("Account-manager binding decides only which agent session answers");
18
+ });
19
+ it("the config skill routes proactive/scheduled manager sends to agentDispatch", () => {
20
+ expect(SKILL).toContain("An account manager is a valid dispatch destination");
21
+ expect(SKILL).toContain("`whatsapp-send-admin` (the immediate proactive-push tool) accepts admin phones only");
22
+ });
23
+ it("the config skill forbids the false line-ownership moves", () => {
24
+ expect(SKILL).toContain('"pointed at the wrong account"');
25
+ expect(SKILL).toContain("no such flip and no such restriction");
26
+ expect(SKILL).toContain("never for reaching an account manager");
27
+ });
28
+ it("the reference carries the explicit no-per-recipient-restriction statement", () => {
29
+ expect(REFERENCE).toContain("No per-recipient deliverability restriction");
30
+ expect(REFERENCE).toContain("socket ownership does not gate the recipient");
31
+ });
32
+ });
33
+ //# sourceMappingURL=deliverability-grounding.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deliverability-grounding.test.js","sourceRoot":"","sources":["../../src/__tests__/deliverability-grounding.test.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,EAAE;AACF,4EAA4E;AAC5E,+EAA+E;AAC/E,8EAA8E;AAC9E,kFAAkF;AAClF,iFAAiF;AACjF,mFAAmF;AACnF,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,IAAI,CAAC;AAClC,OAAO,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAE/B,MAAM,KAAK,GAAG,YAAY,CACxB,OAAO,CAAC,SAAS,EAAE,iDAAiD,CAAC,EACrE,MAAM,CACP,CAAC;AACF,MAAM,SAAS,GAAG,YAAY,CAC5B,OAAO,CAAC,SAAS,EAAE,0CAA0C,CAAC,EAC9D,MAAM,CACP,CAAC;AAEF,QAAQ,CAAC,mCAAmC,EAAE,GAAG,EAAE;IACjD,EAAE,CAAC,gEAAgE,EAAE,GAAG,EAAE;QACxE,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,mDAAmD,CAAC,CAAC;QAC7E,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,CACrB,kEAAkE,CACnE,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,4EAA4E,EAAE,GAAG,EAAE;QACpF,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,oDAAoD,CAAC,CAAC;QAC9E,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,CACrB,qFAAqF,CACtF,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE;QACjE,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,gCAAgC,CAAC,CAAC;QAC1D,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,sCAAsC,CAAC,CAAC;QAChE,MAAM,CAAC,KAAK,CAAC,CAAC,SAAS,CACrB,uCAAuC,CACxC,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2EAA2E,EAAE,GAAG,EAAE;QACnF,MAAM,CAAC,SAAS,CAAC,CAAC,SAAS,CAAC,6CAA6C,CAAC,CAAC;QAC3E,MAAM,CAAC,SAAS,CAAC,CAAC,SAAS,CAAC,8CAA8C,CAAC,CAAC;IAC9E,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -109,6 +109,8 @@ The platform enforces this at multiple levels:
109
109
 
110
110
  **Account-manager scoping — the two-account split.** A designated manager's inbound spawns an admin session scoped to their sub-account (its graph, skills, and `${USER_ID}` attribution). Replies and file delivery still go out over the **house's** paired WhatsApp device — the only WhatsApp socket — so the sub-account scope never redirects the transport. The connection (house) account is kept for `startNativeFileFollower` and the reply path. A stale binding (a phone wrongly also on `adminPhones`) is surfaced at boot by `account-manager-drift` and never silently mis-routes.
111
111
 
112
+ **No per-recipient deliverability restriction.** The single paired line can deliver to any number regardless of which account owns the socket. Sub-account scope (from account-manager binding) decides which session runs the turn — its cwd, graph, and tools — and never which numbers a message can reach. To reach an account manager proactively or on a schedule, a scheduled dispatch (`agentDispatch`) accepts the manager as a valid `destination` and delivers over this same house line; there is no "point the line at the sub-account" step, because socket ownership does not gate the recipient.
113
+
112
114
  **Single source of truth + fail-closed.** The effective account is resolved **exactly once**, in the gate: `checkDmAccess` returns `effectiveAccountId` (the house account for owner/`adminPhones`/public, the bound sub-account for a manager), and that value threads through the inbound payload → gateway → `ensureChannelSession`, which spawns into it **without re-reading** the `accountManagers` map. There is no second resolution and no `?? accountId` house fallback: if a manager's bound sub-account is not a valid account, the inbound is **rejected** (no session spawned, no reply), never routed to the house. This closes the escalation where a divergence between two independent map reads handed a scoped manager a house-owner admin session. Observable signals: `op=account-manager-route … effectiveAccount=… source=gate` on a routed manager inbound; `op=account-manager-reject … reason=unresolved-effective-account` on the fail-closed drop; a standing `op=escalation-tripwire` belt that can only fire if a future change reintroduces the divergence.
113
115
 
114
116
  **The scheduler path fails closed too.** A scheduled dispatch (`POST /api/channel/schedule-inject`) reaches the same spawn machine without going through the gate, resolving its own effective account via `effectiveAccountFor`. That resolver carries the same single-source + fail-closed shape: a non-manager destination scopes to the house (an owner/admin's real scope), a valid manager scopes to the bound sub-account, and a manager whose bound sub-account is not a valid account resolves to nothing — the route **rejects** (`op=schedule-account-manager-reject … reason=unresolved-effective-account`, HTTP 403, no spawn, no reply), never routing to the house. There is no `?? accountId` fallback on this path either. Telegram scheduled dispatch is house-only by construction (no account-manager routing), so there is nothing to fail closed there.
@@ -41,7 +41,11 @@ Call `whatsapp-config action: list-account-managers` to show the current phone-t
41
41
 
42
42
  A phone is a house admin or a sub-account manager, never both. Binding a phone that is already on the admin list is refused, and vice versa. To move a house admin to a sub-account, remove it from the admin list first, then add it as an account manager.
43
43
 
44
- Replies to an account manager still go out over the house's paired WhatsApp device. The agent cannot push a proactive message to a manager with `whatsapp-send-admin` (that path is limited to admin phones), so message a manager from the paired device directly if you need to start the conversation.
44
+ Replies to an account manager go out over the house's paired WhatsApp device. That single paired line is the only WhatsApp transport, and it can deliver to any number: which account owns the socket never restricts which numbers a message can reach. Account-manager binding decides only which agent session answers an inbound or runs a scheduled turn, not deliverability.
45
+
46
+ To message a manager proactively or on a schedule, create a scheduled dispatch (`agentDispatch` on `schedule-event`) with the manager's phone as the `destination`. An account manager is a valid dispatch destination, and the turn's reply is delivered over the house's paired line. The one real limitation is that `whatsapp-send-admin` (the immediate proactive-push tool) accepts admin phones only, not managers — a narrow tool boundary, not a line-ownership problem.
47
+
48
+ Never tell the operator the line is "pointed at the wrong account", offer to point or flip the WhatsApp line at a sub-account, or describe an install-wide line flip: there is no such flip and no such restriction. Do not offer Telegram as a workaround here. Telegram is the right recommendation only for genuine Meta-policy limits (group sends, cold first-contact), never for reaching an account manager.
45
49
 
46
50
  **Public agent:**
47
51
 
@@ -1,8 +1,9 @@
1
- /** Mutate `env` in place to authenticate a darwin-spawned claude against the
2
- * platform's credentials FILE, then return it. No-op on every other platform and
3
- * when `claudeConfigDir` is empty. `claudeConfigDir` is the brand's `.claude`
4
- * dir; the credentials file is its `.credentials.json` child (the same path
5
- * auth-health reads). When `logger` is passed, emits one `op=spawn-hostcreds`
6
- * line recording what was resolved and whether a token was applied. */
7
- export declare function applyHostCredsEnv(env: NodeJS.ProcessEnv, claudeConfigDir: string, platform?: NodeJS.Platform, logger?: (line: string) => void): NodeJS.ProcessEnv;
1
+ import { type KeychainIO } from './claude-keychain-write.js';
2
+ /** Mutate `env` in place to authenticate a darwin-spawned claude, then return it.
3
+ * No-op on every other platform and when `claudeConfigDir` is empty. On darwin
4
+ * with a valid credentials file it write-through the full blob to the Keychain
5
+ * and skips the env token; on an unresolved item or write failure it injects the
6
+ * token instead. When `logger` is passed, emits one `op=spawn-hostcreds` line and
7
+ * one `op=connector-availability` verdict. `io` is injected for tests. */
8
+ export declare function applyHostCredsEnv(env: NodeJS.ProcessEnv, claudeConfigDir: string, platform?: NodeJS.Platform, logger?: (line: string) => void, io?: KeychainIO): NodeJS.ProcessEnv;
8
9
  //# sourceMappingURL=claude-host-creds.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"claude-host-creds.d.ts","sourceRoot":"","sources":["../src/claude-host-creds.ts"],"names":[],"mappings":"AAmCA;;;;;wEAKwE;AACxE,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,CAAC,UAAU,EACtB,eAAe,EAAE,MAAM,EACvB,QAAQ,GAAE,MAAM,CAAC,QAA2B,EAC5C,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAC9B,MAAM,CAAC,UAAU,CA4BnB"}
1
+ {"version":3,"file":"claude-host-creds.d.ts","sourceRoot":"","sources":["../src/claude-host-creds.ts"],"names":[],"mappings":"AA8BA,OAAO,EAGL,KAAK,UAAU,EAChB,MAAM,4BAA4B,CAAA;AAOnC;;;;;2EAK2E;AAC3E,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,CAAC,UAAU,EACtB,eAAe,EAAE,MAAM,EACvB,QAAQ,GAAE,MAAM,CAAC,QAA2B,EAC5C,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,EAC/B,EAAE,GAAE,UAA6B,GAChC,MAAM,CAAC,UAAU,CAyDnB"}
@@ -1,66 +1,89 @@
1
- // Task 1406 — macOS credential-store bridge (supersedes the Task 1395 gap-1
2
- // wiring, which was inert on CLI v2.1.201).
1
+ // Task 1406 / 1431 — macOS credential-store bridge.
3
2
  //
4
- // On macOS the `claude` CLI stores its OAuth token in the login Keychain, while
5
- // this platform reads and refreshes the token from a FILE
6
- // (`<CLAUDE_CONFIG_DIR>/.credentials.json`, the path the UI server's
7
- // readCredentials()/writeOAuthCredentials() own). Left alone the two never meet:
8
- // the child authenticates against a stale/empty Keychain and its first API turn
9
- // prints `Not logged in · Please run /login`, even though platform auth-health
10
- // reads the FILE and reports `ok`.
3
+ // On macOS the `claude` CLI keeps its login in the login Keychain, while this
4
+ // platform reads and refreshes the token from a FILE
5
+ // (`<CLAUDE_CONFIG_DIR>/.credentials.json`, owned by the UI server's
6
+ // readCredentials()/writeOAuthCredentials()). Left alone the two never meet: the
7
+ // child authenticates against a stale/empty Keychain.
11
8
  //
12
- // Task 1395 tried to bridge this by pointing CLAUDE_CODE_HOST_CREDS_FILE at the
13
- // credentials file. Reading the shipped CLI v2.1.201, that env var does NOT mean
14
- // "read OAuth creds from here". It engages a host-orchestration handoff only when
15
- // CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST is also truthy, and it expects the file to
16
- // be the CLI's internal `{procStart:<number>, env:{…}}` shape not the
17
- // `{claudeAiOauth:{…}}` OAuth blob. The platform set neither the companion flag
18
- // nor that shape, so the CLI ignored the file and fell back to the Keychain. The
19
- // wiring was a no-op.
9
+ // Task 1406 bridged this by injecting the file's access token as
10
+ // CLAUDE_CODE_OAUTH_TOKEN. That restored inference, but an env token is treated
11
+ // as API-key precedence by the CLI (`So()` false), which disables the claude.ai
12
+ // account-connector sync the darwin agent saw zero `mcp__claude_ai_*` tools
13
+ // (Task 1431). The child banner read `Claude API`, not the Max subscription.
20
14
  //
21
- // The correct darwin bridge injects the access token directly. CLAUDE_CODE_OAUTH_TOKEN
22
- // is a first-class auth source ("ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN,
23
- // CLAUDE_CODE_OAUTH_TOKEN, or WIF env vars required") and is honoured on the
24
- // `--remote-control` session path (only the background-task spawn path scrubs it
25
- // on macOS). Read at spawn time from the same file auth-health keeps fresh, so
26
- // the child always carries the current token and the host stays the single
27
- // refresh owner the intent Task 1395 aimed for.
15
+ // Task 1431 replaces the injection with a Keychain WRITE-THROUGH: copy the file's
16
+ // full claudeAiOauth blob into the exact login-Keychain item the CLI reads, and
17
+ // then do NOT set CLAUDE_CODE_OAUTH_TOKEN. The child resolves a real subscription
18
+ // login from the Keychain → inference works AND the account-connector sync runs.
19
+ // The host stays the primary refresh owner: auth-health refreshes the file and
20
+ // each spawn re-syncs the Keychain from it. (The residual mid-session self-refresh
21
+ // race is tracked by Tasks 1426/1427, not solved here.)
28
22
  //
29
- // Darwin-only. On Linux `claude` already reads/refreshes the same file under
30
- // CLAUDE_CONFIG_DIR, so no env change is needed there; the regression boundary is
31
- // that the Linux env is byte-for-byte unchanged and no extra file read happens.
23
+ // When the Keychain item can't be resolved (never computing the version-coupled
24
+ // <hash> Task 1398) or the write fails, the bridge falls back to the Task 1406
25
+ // token injection so inference never breaks; connectors stay off until a login
26
+ // re-establishes the item. Darwin-only; Linux reads/refreshes the same file under
27
+ // CLAUDE_CONFIG_DIR directly, so its env is byte-for-byte unchanged.
32
28
  import { existsSync, readFileSync } from 'node:fs';
33
29
  import { join } from 'node:path';
34
- /** Mutate `env` in place to authenticate a darwin-spawned claude against the
35
- * platform's credentials FILE, then return it. No-op on every other platform and
36
- * when `claudeConfigDir` is empty. `claudeConfigDir` is the brand's `.claude`
37
- * dir; the credentials file is its `.credentials.json` child (the same path
38
- * auth-health reads). When `logger` is passed, emits one `op=spawn-hostcreds`
39
- * line recording what was resolved and whether a token was applied. */
40
- export function applyHostCredsEnv(env, claudeConfigDir, platform = process.platform, logger) {
30
+ import { realKeychainIO, resolveKeychain, } from './claude-keychain-write.js';
31
+ import { classifyConnectorAvailability, credsHaveConnectorScope, } from './connector-availability.js';
32
+ /** Mutate `env` in place to authenticate a darwin-spawned claude, then return it.
33
+ * No-op on every other platform and when `claudeConfigDir` is empty. On darwin
34
+ * with a valid credentials file it write-through the full blob to the Keychain
35
+ * and skips the env token; on an unresolved item or write failure it injects the
36
+ * token instead. When `logger` is passed, emits one `op=spawn-hostcreds` line and
37
+ * one `op=connector-availability` verdict. `io` is injected for tests. */
38
+ export function applyHostCredsEnv(env, claudeConfigDir, platform = process.platform, logger, io = realKeychainIO()) {
41
39
  if (platform !== 'darwin' || !claudeConfigDir)
42
40
  return env;
43
41
  const credsFile = join(claudeConfigDir, '.credentials.json');
44
42
  const fileExists = existsSync(credsFile);
45
43
  let expiresAt = null;
46
- let tokenApplied = false;
44
+ let accessToken = null;
45
+ let rawBlob = '';
47
46
  if (fileExists) {
48
47
  try {
49
- const parsed = JSON.parse(readFileSync(credsFile, 'utf8'));
48
+ rawBlob = readFileSync(credsFile, 'utf8');
49
+ const parsed = JSON.parse(rawBlob);
50
50
  const oauth = parsed.claudeAiOauth;
51
51
  if (typeof oauth?.expiresAt === 'number')
52
52
  expiresAt = oauth.expiresAt;
53
53
  if (typeof oauth?.accessToken === 'string' && oauth.accessToken.length > 0) {
54
- env.CLAUDE_CODE_OAUTH_TOKEN = oauth.accessToken;
55
- tokenApplied = true;
54
+ accessToken = oauth.accessToken;
56
55
  }
57
56
  }
58
57
  catch {
59
58
  // Malformed/unreadable file: leave the child unauthenticated rather than
60
- // throw and wedge the spawn. tokenApplied stays false; the logger records it.
59
+ // throw and wedge the spawn. mode stays 'none'; the logger records it.
61
60
  }
62
61
  }
63
- logger?.(`[spawn] op=spawn-hostcreds platform=darwin credsFile=${credsFile} fileExists=${fileExists} expiresAt=${expiresAt ?? 'none'} configDir=${claudeConfigDir} tokenApplied=${tokenApplied}`);
62
+ let mode = 'none';
63
+ let keychainService = 'none';
64
+ if (accessToken) {
65
+ const resolved = resolveKeychain(claudeConfigDir, io);
66
+ if (resolved && io.writePassword(resolved.service, resolved.account, rawBlob)) {
67
+ // Write-through succeeded → the child reads a real subscription login from
68
+ // the Keychain. Do NOT set the env token (it would force API mode).
69
+ mode = 'keychain-writeback';
70
+ keychainService = resolved.service;
71
+ }
72
+ else {
73
+ // Unresolved item or failed write → preserve inference with the token.
74
+ env.CLAUDE_CODE_OAUTH_TOKEN = accessToken;
75
+ mode = 'token-fallback';
76
+ keychainService = resolved ? `${resolved.service}:write-failed` : 'unresolved';
77
+ }
78
+ }
79
+ logger?.(`[spawn] op=spawn-hostcreds platform=darwin credsFile=${credsFile} fileExists=${fileExists} expiresAt=${expiresAt ?? 'none'} configDir=${claudeConfigDir} mode=${mode} keychainService=${keychainService} tokenApplied=${mode === 'token-fallback'}`);
80
+ // Cheap spawn-time reconciliation: connectors are expected when the file carries
81
+ // a scoped subscription token; if the spawn fell back to token injection the env
82
+ // token will suppress them (verdict=blocked-by-env-token). `loaded` is unknown at
83
+ // spawn — the post-condition probe is the standing audit (connector-availability).
84
+ const expected = credsHaveConnectorScope(rawBlob);
85
+ const verdict = classifyConnectorAvailability({ expected, mode, loaded: null });
86
+ logger?.(`[spawn] op=connector-availability phase=spawn configDir=${claudeConfigDir} expected=${expected} mode=${mode} verdict=${verdict}`);
64
87
  return env;
65
88
  }
66
89
  //# sourceMappingURL=claude-host-creds.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"claude-host-creds.js","sourceRoot":"","sources":["../src/claude-host-creds.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,4CAA4C;AAC5C,EAAE;AACF,gFAAgF;AAChF,0DAA0D;AAC1D,qEAAqE;AACrE,iFAAiF;AACjF,gFAAgF;AAChF,+EAA+E;AAC/E,mCAAmC;AACnC,EAAE;AACF,gFAAgF;AAChF,iFAAiF;AACjF,kFAAkF;AAClF,kFAAkF;AAClF,wEAAwE;AACxE,gFAAgF;AAChF,iFAAiF;AACjF,sBAAsB;AACtB,EAAE;AACF,uFAAuF;AACvF,0EAA0E;AAC1E,+EAA+E;AAC/E,iFAAiF;AACjF,+EAA+E;AAC/E,2EAA2E;AAC3E,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,kFAAkF;AAClF,gFAAgF;AAEhF,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAClD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAEhC;;;;;wEAKwE;AACxE,MAAM,UAAU,iBAAiB,CAC/B,GAAsB,EACtB,eAAuB,EACvB,WAA4B,OAAO,CAAC,QAAQ,EAC5C,MAA+B;IAE/B,IAAI,QAAQ,KAAK,QAAQ,IAAI,CAAC,eAAe;QAAE,OAAO,GAAG,CAAA;IAEzD,MAAM,SAAS,GAAG,IAAI,CAAC,eAAe,EAAE,mBAAmB,CAAC,CAAA;IAC5D,MAAM,UAAU,GAAG,UAAU,CAAC,SAAS,CAAC,CAAA;IACxC,IAAI,SAAS,GAAkB,IAAI,CAAA;IACnC,IAAI,YAAY,GAAG,KAAK,CAAA;IACxB,IAAI,UAAU,EAAE,CAAC;QACf,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,SAAS,EAAE,MAAM,CAAC,CAExD,CAAA;YACD,MAAM,KAAK,GAAG,MAAM,CAAC,aAAa,CAAA;YAClC,IAAI,OAAO,KAAK,EAAE,SAAS,KAAK,QAAQ;gBAAE,SAAS,GAAG,KAAK,CAAC,SAAS,CAAA;YACrE,IAAI,OAAO,KAAK,EAAE,WAAW,KAAK,QAAQ,IAAI,KAAK,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBAC3E,GAAG,CAAC,uBAAuB,GAAG,KAAK,CAAC,WAAW,CAAA;gBAC/C,YAAY,GAAG,IAAI,CAAA;YACrB,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,yEAAyE;YACzE,8EAA8E;QAChF,CAAC;IACH,CAAC;IAED,MAAM,EAAE,CACN,wDAAwD,SAAS,eAAe,UAAU,cAAc,SAAS,IAAI,MAAM,cAAc,eAAe,iBAAiB,YAAY,EAAE,CACxL,CAAA;IACD,OAAO,GAAG,CAAA;AACZ,CAAC"}
1
+ {"version":3,"file":"claude-host-creds.js","sourceRoot":"","sources":["../src/claude-host-creds.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ export interface KeychainItem {
2
+ service: string;
3
+ /** `YYYYMMDDHHMMSSZ` modified-time — lexicographically sortable. */
4
+ mdat: string;
5
+ }
6
+ export interface KeychainIO {
7
+ /** Persisted service name for this config dir, or null if none captured yet. */
8
+ readPersisted: (configDir: string) => string | null;
9
+ /** Record the resolved service name for this config dir. */
10
+ persist: (configDir: string, service: string) => void;
11
+ /** All `Claude Code-credentials*` generic-password items with their mdat. */
12
+ listItems: () => KeychainItem[];
13
+ /** The `acct` attribute of the named item, or null if the item is absent. */
14
+ getAccount: (service: string) => string | null;
15
+ /** Replace the item's password with `blob`. Returns false on any failure. */
16
+ writePassword: (service: string, account: string, blob: string) => boolean;
17
+ }
18
+ export interface ResolvedKeychain {
19
+ service: string;
20
+ account: string;
21
+ }
22
+ /** Parse `security dump-keychain` output into `Claude Code-credentials*` items
23
+ * paired with their mdat. `mdat` precedes `svce` in each block, matching the
24
+ * dump layout. Pure. */
25
+ export declare function parseKeychainItems(dumpOutput: string): KeychainItem[];
26
+ /** Parse the `acct` blob from `security find-generic-password` attribute output.
27
+ * Returns null when no acct attribute is present. Pure. */
28
+ export declare function parseKeychainAccount(attrOutput: string): string | null;
29
+ /** Resolve the Keychain item for `configDir`, never computing the hash. The
30
+ * newest hashed item wins (self-heals across a hash rotation); a genuine mdat
31
+ * tie is broken by the persisted login-writeback name, and an unbreakable tie
32
+ * returns null (never guess). */
33
+ export declare function resolveKeychain(configDir: string, io: KeychainIO): ResolvedKeychain | null;
34
+ /** Real `security`/filesystem implementation of KeychainIO. Every method absorbs
35
+ * its own errors so a Keychain fault degrades to token-fallback rather than
36
+ * throwing into the spawn path. */
37
+ export declare function realKeychainIO(): KeychainIO;
38
+ /** The OS username, the `acct` the CLI stores its credentials item under. */
39
+ export declare function currentAccount(): string;
40
+ //# sourceMappingURL=claude-keychain-write.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claude-keychain-write.d.ts","sourceRoot":"","sources":["../src/claude-keychain-write.ts"],"names":[],"mappings":"AAsCA,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAA;IACf,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,UAAU;IACzB,gFAAgF;IAChF,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAA;IACnD,4DAA4D;IAC5D,OAAO,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;IACrD,6EAA6E;IAC7E,SAAS,EAAE,MAAM,YAAY,EAAE,CAAA;IAC/B,6EAA6E;IAC7E,UAAU,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAA;IAC9C,6EAA6E;IAC7E,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAA;CAC3E;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;CAChB;AAED;;yBAEyB;AACzB,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,YAAY,EAAE,CAerE;AAED;4DAC4D;AAC5D,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAMtE;AAED;;;kCAGkC;AAClC,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,UAAU,GAAG,gBAAgB,GAAG,IAAI,CAoB1F;AAED;;oCAEoC;AACpC,wBAAgB,cAAc,IAAI,UAAU,CAuD3C;AAED,6EAA6E;AAC7E,wBAAgB,cAAc,IAAI,MAAM,CAMvC"}
@@ -0,0 +1,159 @@
1
+ // Task 1431 — darwin keychain write-through.
2
+ //
3
+ // macOS `claude` reads its login from the login Keychain, not the file. The
4
+ // account-connector sync (`/v1/mcp_servers` fetch) runs only when the resolved
5
+ // Keychain creds carry a subscription accessToken with the `user:mcp_servers`
6
+ // scope AND no env auth source takes precedence. The Task 1406 bridge injected
7
+ // CLAUDE_CODE_OAUTH_TOKEN from the file, which restored inference but forced the
8
+ // CLI into API-key precedence (`So()` false) and silently disabled every
9
+ // claude.ai account connector. This module instead writes the file's full
10
+ // claudeAiOauth blob into the Keychain item the CLI reads, so the child is a real
11
+ // subscription login.
12
+ //
13
+ // The Keychain item is a login-keychain generic-password whose service is
14
+ // `Claude Code-credentials-<hash>`; the <hash> is claude-internal and
15
+ // version-coupled, so we NEVER compute it (Task 1398). We resolve the item by
16
+ // discovering the NEWEST hashed item (the one a login or a prior write-through
17
+ // last touched), and only ever write to an item that already exists — the
18
+ // service name is discovered, never fabricated. Newest-by-mdat self-heals across
19
+ // a CLI hash rotation: the old item lingers (macOS never deletes it) but the
20
+ // re-login writes a newer one, so a stale persisted name can never redirect the
21
+ // write to a dead item. The persisted name (captured at login-writeback) only
22
+ // breaks a genuine mdat tie. This targets the single-brand darwin dev runtime;
23
+ // two brands actively spawning on one Mac is not a supported darwin topology.
24
+ //
25
+ // Darwin-only; all callers no-op elsewhere. All `security` IO is injected so the
26
+ // resolver and writer are unit-tested with fakes.
27
+ import { execFileSync } from 'node:child_process';
28
+ import { readFileSync, writeFileSync } from 'node:fs';
29
+ import { userInfo } from 'node:os';
30
+ import { join } from 'node:path';
31
+ const CREDS_SERVICE_PREFIX = 'Claude Code-credentials';
32
+ /** Hashed brand variants only: `Claude Code-credentials-<suffix>`. Excludes the
33
+ * bare desktop item `Claude Code-credentials` (no dash suffix). */
34
+ const HASHED_SERVICE_RE = /^Claude Code-credentials-.+$/;
35
+ const PERSIST_FILENAME = '.keychain-service';
36
+ /** Parse `security dump-keychain` output into `Claude Code-credentials*` items
37
+ * paired with their mdat. `mdat` precedes `svce` in each block, matching the
38
+ * dump layout. Pure. */
39
+ export function parseKeychainItems(dumpOutput) {
40
+ const out = [];
41
+ let pendingMdat = null;
42
+ for (const line of dumpOutput.split('\n')) {
43
+ const mdatMatch = line.match(/"mdat"<timedate>=0x[0-9A-Fa-f]*\s+"([0-9]{14}Z)\\000"/);
44
+ if (mdatMatch) {
45
+ pendingMdat = mdatMatch[1];
46
+ continue;
47
+ }
48
+ const svceMatch = line.match(/"svce"<blob>="(.+)"\s*$/);
49
+ if (svceMatch) {
50
+ if (svceMatch[1].startsWith(CREDS_SERVICE_PREFIX) && pendingMdat) {
51
+ out.push({ service: svceMatch[1], mdat: pendingMdat });
52
+ }
53
+ pendingMdat = null;
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ /** Parse the `acct` blob from `security find-generic-password` attribute output.
59
+ * Returns null when no acct attribute is present. Pure. */
60
+ export function parseKeychainAccount(attrOutput) {
61
+ for (const line of attrOutput.split('\n')) {
62
+ const m = line.match(/"acct"<blob>="(.*)"\s*$/);
63
+ if (m)
64
+ return m[1];
65
+ }
66
+ return null;
67
+ }
68
+ /** Resolve the Keychain item for `configDir`, never computing the hash. The
69
+ * newest hashed item wins (self-heals across a hash rotation); a genuine mdat
70
+ * tie is broken by the persisted login-writeback name, and an unbreakable tie
71
+ * returns null (never guess). */
72
+ export function resolveKeychain(configDir, io) {
73
+ const hashed = io.listItems().filter((i) => HASHED_SERVICE_RE.test(i.service));
74
+ if (hashed.length === 0)
75
+ return null;
76
+ const newestMdat = hashed.reduce((m, i) => (i.mdat > m ? i.mdat : m), hashed[0].mdat);
77
+ const newest = hashed.filter((i) => i.mdat === newestMdat);
78
+ let service;
79
+ if (newest.length === 1) {
80
+ service = newest[0].service;
81
+ }
82
+ else {
83
+ const persisted = io.readPersisted(configDir);
84
+ service = persisted && newest.some((i) => i.service === persisted) ? persisted : null;
85
+ }
86
+ if (!service)
87
+ return null;
88
+ const account = io.getAccount(service);
89
+ if (account === null)
90
+ return null;
91
+ io.persist(configDir, service);
92
+ return { service, account };
93
+ }
94
+ /** Real `security`/filesystem implementation of KeychainIO. Every method absorbs
95
+ * its own errors so a Keychain fault degrades to token-fallback rather than
96
+ * throwing into the spawn path. */
97
+ export function realKeychainIO() {
98
+ return {
99
+ readPersisted: (configDir) => {
100
+ try {
101
+ const s = readFileSync(join(configDir, PERSIST_FILENAME), 'utf8').trim();
102
+ return s.length > 0 ? s : null;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ },
108
+ persist: (configDir, service) => {
109
+ try {
110
+ writeFileSync(join(configDir, PERSIST_FILENAME), service, { mode: 0o600 });
111
+ }
112
+ catch {
113
+ // Best-effort: a failed persist just means the next spawn re-discovers.
114
+ }
115
+ },
116
+ listItems: () => {
117
+ try {
118
+ return parseKeychainItems(execFileSync('security', ['dump-keychain'], { encoding: 'utf-8', timeout: 10_000 }));
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ },
124
+ getAccount: (service) => {
125
+ try {
126
+ return parseKeychainAccount(execFileSync('security', ['find-generic-password', '-s', service], {
127
+ encoding: 'utf-8',
128
+ timeout: 10_000,
129
+ }));
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ },
135
+ writePassword: (service, account, blob) => {
136
+ try {
137
+ // The blob (incl. the refresh token) rides the argv of `security`, so it
138
+ // is briefly visible to same-user `ps -ww`. `add-generic-password` has no
139
+ // stdin path for the password, and the source file is already same-user-
140
+ // readable, so this adds no exposure beyond what a same-user attacker has.
141
+ execFileSync('security', ['add-generic-password', '-U', '-s', service, '-a', account, '-w', blob], { timeout: 10_000 });
142
+ return true;
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ },
148
+ };
149
+ }
150
+ /** The OS username, the `acct` the CLI stores its credentials item under. */
151
+ export function currentAccount() {
152
+ try {
153
+ return userInfo().username;
154
+ }
155
+ catch {
156
+ return '';
157
+ }
158
+ }
159
+ //# sourceMappingURL=claude-keychain-write.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claude-keychain-write.js","sourceRoot":"","sources":["../src/claude-keychain-write.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ export type ConnectorVerdict = 'not-expected' | 'blocked-by-env-token' | 'pending' | 'expected-not-loaded' | 'ok';
2
+ /** True when the credentials file body carries a non-empty claudeAiOauth
3
+ * accessToken AND the `user:mcp_servers` scope (both gates for the CLI's
4
+ * account-connector fetch). Never throws. Pure. */
5
+ export declare function credsHaveConnectorScope(fileBody: string): boolean;
6
+ /** Count the connected `claude.ai …` servers in `claude mcp list` output. Only
7
+ * `✔ Connected` claude.ai entries count; `! Needs authentication` and non-
8
+ * claude.ai servers are excluded. Pure. */
9
+ export declare function parseLoadedClaudeAiConnectors(mcpListOutput: string): number;
10
+ export type SpawnAuthMode = 'keychain-writeback' | 'token-fallback' | 'none';
11
+ export interface ClassifyInput {
12
+ expected: boolean;
13
+ mode: SpawnAuthMode;
14
+ /** Number of loaded claude.ai connectors, or null when not yet probed. */
15
+ loaded: number | null;
16
+ }
17
+ /** Reduce expected/mode/loaded to a single verdict. When connectors are expected
18
+ * but the spawn fell back to env-token injection, the env token is the known
19
+ * suppressor — `blocked-by-env-token` — regardless of a later probe. Pure. */
20
+ export declare function classifyConnectorAvailability(input: ClassifyInput): ConnectorVerdict;
21
+ export interface AuditInput {
22
+ configDir: string;
23
+ mode: SpawnAuthMode;
24
+ readCredsFile: (configDir: string) => string;
25
+ runMcpList: (configDir: string) => string;
26
+ log: (line: string) => void;
27
+ }
28
+ export interface AuditResult {
29
+ expected: boolean;
30
+ loaded: number;
31
+ verdict: ConnectorVerdict;
32
+ }
33
+ /** Run the full reconciliation: read the file to decide `expected`, probe the CLI
34
+ * for `loaded`, classify, and emit one `op=connector-availability` line. */
35
+ export declare function auditConnectorAvailability(input: AuditInput): AuditResult;
36
+ //# sourceMappingURL=connector-availability.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connector-availability.d.ts","sourceRoot":"","sources":["../src/connector-availability.ts"],"names":[],"mappings":"AAqBA,MAAM,MAAM,gBAAgB,GACxB,cAAc,GACd,sBAAsB,GACtB,SAAS,GACT,qBAAqB,GACrB,IAAI,CAAA;AAER;;oDAEoD;AACpD,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAYjE;AAED;;4CAE4C;AAC5C,wBAAgB,6BAA6B,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAM3E;AAED,MAAM,MAAM,aAAa,GAAG,oBAAoB,GAAG,gBAAgB,GAAG,MAAM,CAAA;AAE5E,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,OAAO,CAAA;IACjB,IAAI,EAAE,aAAa,CAAA;IACnB,0EAA0E;IAC1E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED;;+EAE+E;AAC/E,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,aAAa,GAAG,gBAAgB,CAKpF;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,IAAI,EAAE,aAAa,CAAA;IACnB,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAA;IAC5C,UAAU,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAA;IACzC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CAC5B;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,OAAO,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,gBAAgB,CAAA;CAC1B;AAED;6EAC6E;AAC7E,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,UAAU,GAAG,WAAW,CAQzE"}