evolcore 0.0.1 → 0.0.3
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/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/MIGRATION-0.5.0.md +378 -0
- package/README.md +318 -14
- package/ROLE_ACCESS_CONTROL.md +174 -0
- package/assets/.env.template +4 -0
- package/bin/ec-safe-output.js +161 -0
- package/bin/ec.js +29 -0
- package/dist/agents/baseagent.js +163 -0
- package/dist/agents/claude-runner.js +2565 -0
- package/dist/agents/codex-app-server-client.js +448 -0
- package/dist/agents/codex-runner.js +2682 -0
- package/dist/agents/gemini-runner.js +666 -0
- package/dist/agents/runner-types.js +75 -0
- package/dist/aun/aid/agentmd.js +216 -0
- package/dist/aun/aid/client.js +132 -0
- package/dist/aun/aid/control-aid.js +91 -0
- package/dist/aun/aid/identity.js +518 -0
- package/dist/aun/aid/index.js +4 -0
- package/dist/aun/aid/store.js +74 -0
- package/dist/aun/aid/types.js +1 -0
- package/dist/aun/aid/validation.js +21 -0
- package/dist/aun/group-identity.js +10 -0
- package/dist/aun/msg/group-index.js +6 -0
- package/dist/aun/msg/group.js +1231 -0
- package/dist/aun/msg/history.js +123 -0
- package/dist/aun/msg/index.js +5 -0
- package/dist/aun/msg/p2p.js +393 -0
- package/dist/aun/msg/payload-type.js +27 -0
- package/dist/aun/msg/upload.js +137 -0
- package/dist/aun/outbox.js +168 -0
- package/dist/aun/rpc/caller.js +42 -0
- package/dist/aun/rpc/connection.js +25 -0
- package/dist/aun/rpc/index.js +2 -0
- package/dist/aun/service-proxy.js +225 -0
- package/dist/aun/storage/download.js +29 -0
- package/dist/aun/storage/index.js +3 -0
- package/dist/aun/storage/manage.js +10 -0
- package/dist/aun/storage/upload.js +68 -0
- package/dist/channels/aun.js +4164 -0
- package/dist/channels/contact-bind-code.js +134 -0
- package/dist/channels/daemon.js +422 -0
- package/dist/channels/dingtalk.js +1479 -0
- package/dist/channels/feishu.js +1865 -0
- package/dist/channels/qqbot.js +409 -0
- package/dist/channels/wechat.js +817 -0
- package/dist/channels/wecom-card.js +101 -0
- package/dist/channels/wecom-onboarding.js +82 -0
- package/dist/channels/wecom-state.js +191 -0
- package/dist/channels/wecom.js +1157 -0
- package/dist/cli/agent-command.js +642 -0
- package/dist/cli/agent.js +1059 -0
- package/dist/cli/aun-commands.js +2003 -0
- package/dist/cli/bench.js +1228 -0
- package/dist/cli/cli-argv.js +66 -0
- package/dist/cli/code-stats.js +329 -0
- package/dist/cli/command-log.js +82 -0
- package/dist/cli/config-selector.js +69 -0
- package/dist/cli/config.js +261 -0
- package/dist/cli/contact.js +71 -0
- package/dist/cli/ctl-command.js +62 -0
- package/dist/cli/daemon-commands.js +2750 -0
- package/dist/cli/fs-command.js +1447 -0
- package/dist/cli/handoff-command.js +302 -0
- package/dist/cli/help.js +35 -0
- package/dist/cli/index.js +374 -0
- package/dist/cli/init-channel.js +1372 -0
- package/dist/cli/init.js +590 -0
- package/dist/cli/link-rules.js +240 -0
- package/dist/cli/model.js +591 -0
- package/dist/cli/net-check.js +723 -0
- package/dist/cli/queue-command.js +150 -0
- package/dist/cli/raw-key-input.js +25 -0
- package/dist/cli/response.js +344 -0
- package/dist/cli/restart-monitor.js +480 -0
- package/dist/cli/stats.js +609 -0
- package/dist/cli/task-context.js +80 -0
- package/dist/cli/trigger-command.js +545 -0
- package/dist/cli/version.js +93 -0
- package/dist/cli/watch-logs.js +33 -0
- package/dist/cli/watch-msg.js +673 -0
- package/dist/config/boot-log.js +266 -0
- package/dist/config/builtin-role-templates.js +42 -0
- package/dist/config/builtin-roles.js +91 -0
- package/dist/config/config-batch-get.js +11 -0
- package/dist/config/config-field-policy.js +261 -0
- package/dist/config/config-manager.js +1120 -0
- package/dist/config/config-operation-service.js +384 -0
- package/dist/config/contact-alias.js +68 -0
- package/dist/config/contact-book-store.js +454 -0
- package/dist/config/contact-book-v2-startup.js +35 -0
- package/dist/config/contact-book.js +224 -0
- package/dist/config/contact-operation-service.js +110 -0
- package/dist/config/gateway-config.js +858 -0
- package/dist/config/lifecycle.js +17 -0
- package/dist/config/mention-mode.js +27 -0
- package/dist/config/merge.js +161 -0
- package/dist/config/owner-policy.js +4 -0
- package/dist/config/peer-role-resolver.js +218 -0
- package/dist/config/resolved-config-op.js +483 -0
- package/dist/config/role-config-v4-startup.js +32 -0
- package/dist/config/role-config-v5-startup.js +27 -0
- package/dist/config/role-ranks.js +18 -0
- package/dist/config/role-schema.js +105 -0
- package/dist/config/role-service.js +156 -0
- package/dist/config/role-store.js +215 -0
- package/dist/config/roles.js +64 -0
- package/dist/config/schema-registry.js +154 -0
- package/dist/config/snapshot.js +598 -0
- package/dist/config-store.js +501 -0
- package/dist/core/auth/agent-delegation.js +111 -0
- package/dist/core/auth/auth-gateway.js +166 -0
- package/dist/core/auth/authenticated-actor.js +6 -0
- package/dist/core/auth/authorization-audit.js +119 -0
- package/dist/core/auth/operation-authorizer.js +720 -0
- package/dist/core/auth/operation-catalog.js +731 -0
- package/dist/core/baseagent-loader.js +54 -0
- package/dist/core/bootstrap-service.js +175 -0
- package/dist/core/capability/capability-manager.js +316 -0
- package/dist/core/capability/providers/claude-capability-provider.js +176 -0
- package/dist/core/capability/providers/codex-capability-provider.js +148 -0
- package/dist/core/capability/providers/gemini-capability-provider.js +10 -0
- package/dist/core/capability/types.js +27 -0
- package/dist/core/causation/audit.js +103 -0
- package/dist/core/causation/aun-association.js +111 -0
- package/dist/core/causation/context.js +93 -0
- package/dist/core/causation/index.js +4 -0
- package/dist/core/causation/types.js +2 -0
- package/dist/core/channel-loader.js +277 -0
- package/dist/core/command/agent-control.js +616 -0
- package/dist/core/command/cli-intent-parser.js +225 -0
- package/dist/core/command/command-handler.js +1733 -0
- package/dist/core/command/connect-menu.js +374 -0
- package/dist/core/command/menu-handler.js +3452 -0
- package/dist/core/command/menu-protocol.js +247 -0
- package/dist/core/command/role-menu.js +1623 -0
- package/dist/core/command/slash-gate.js +148 -0
- package/dist/core/command/slash-handler.js +3066 -0
- package/dist/core/daemon-file-cache.js +222 -0
- package/dist/core/event-bus.js +32 -0
- package/dist/core/event-catalog.js +810 -0
- package/dist/core/evolagent-registry.js +545 -0
- package/dist/core/evolagent.js +342 -0
- package/dist/core/handoff/dispatcher.js +229 -0
- package/dist/core/handoff/mutex.js +46 -0
- package/dist/core/handoff/runtime.js +324 -0
- package/dist/core/handoff/store.js +537 -0
- package/dist/core/handoff/types.js +12 -0
- package/dist/core/inference/text-inference.js +173 -0
- package/dist/core/interaction-registration.js +10 -0
- package/dist/core/interaction-router.js +278 -0
- package/dist/core/message/create-status.js +67 -0
- package/dist/core/message/im-renderer.js +659 -0
- package/dist/core/message/items-formatter.js +76 -0
- package/dist/core/message/logical-queue-bridge.js +123 -0
- package/dist/core/message/message-bridge.js +874 -0
- package/dist/core/message/message-cache.js +56 -0
- package/dist/core/message/message-log.js +339 -0
- package/dist/core/message/message-processor.js +4 -0
- package/dist/core/message/message-queue.js +1446 -0
- package/dist/core/message/message-utils.js +76 -0
- package/dist/core/message/peer-mode.js +105 -0
- package/dist/core/message/pending-hints.js +232 -0
- package/dist/core/message/response-depth.js +33 -0
- package/dist/core/message/response-engine.js +4020 -0
- package/dist/core/message/response-snapshot.js +83 -0
- package/dist/core/message/send-receipt.js +24 -0
- package/dist/core/message/stream-debouncer.js +139 -0
- package/dist/core/message/stream-idle-monitor.js +124 -0
- package/dist/core/model/config-scope.js +162 -0
- package/dist/core/model/field-scope.js +78 -0
- package/dist/core/model/model-catalog.js +227 -0
- package/dist/core/model/model-diagnostics.js +182 -0
- package/dist/core/model/model-permission.js +90 -0
- package/dist/core/permission/approval-gateway.js +1017 -0
- package/dist/core/permission/ec-command-parser.js +347 -0
- package/dist/core/permission/execution-sandbox.js +16 -0
- package/dist/core/permission/index.js +6 -0
- package/dist/core/permission/mode.js +24 -0
- package/dist/core/permission/sandbox-runtime.js +265 -0
- package/dist/core/permission/tool-policy.js +1019 -0
- package/dist/core/permission/unix-socket-policy.js +99 -0
- package/dist/core/protected-paths.js +332 -0
- package/dist/core/relation/peer-identity.js +222 -0
- package/dist/core/relation/peer-key.js +1 -0
- package/dist/core/role/runtime-policy.js +141 -0
- package/dist/core/session/adapters/claude-session-file-adapter.js +218 -0
- package/dist/core/session/adapters/codex-session-file-adapter.js +333 -0
- package/dist/core/session/adapters/gemini-session-file-adapter.js +181 -0
- package/dist/core/session/session-file-adapter.js +7 -0
- package/dist/core/session/session-file-health.js +45 -0
- package/dist/core/session/session-fs-store.js +273 -0
- package/dist/core/session/session-key.js +24 -0
- package/dist/core/session/session-manager.js +1643 -0
- package/dist/core/session/session-mapper.js +100 -0
- package/dist/core/session/session-renew.js +314 -0
- package/dist/core/session/session-title.js +128 -0
- package/dist/core/session/session-turn-coordinator.js +205 -0
- package/dist/core/session/session-turns.js +67 -0
- package/dist/core/system-channels.js +29 -0
- package/dist/eck/baseagent-caps.js +18 -0
- package/dist/eck/detect.js +47 -0
- package/dist/eck/group-rules-sync.js +345 -0
- package/dist/eck/init.js +77 -0
- package/dist/eck/kit-renderer.js +359 -0
- package/dist/eck/manifest-engine.js +446 -0
- package/dist/eck/message-renderer.js +199 -0
- package/dist/eck/rules-loader.js +28 -0
- package/dist/index.js +2926 -4
- package/dist/ipc.js +777 -0
- package/dist/paths.js +262 -0
- package/dist/product.js +18 -0
- package/dist/response-system/context-builder.js +71 -0
- package/dist/response-system/coordinator.js +117 -0
- package/dist/response-system/decision-executor.js +86 -0
- package/dist/response-system/engines/v1/index.js +21 -0
- package/dist/response-system/engines/v1/interactive-flow.js +27 -0
- package/dist/response-system/engines/v1/proactive-flow.js +137 -0
- package/dist/response-system/engines/v1/types.js +1 -0
- package/dist/response-system/extensions.js +41 -0
- package/dist/response-system/index.js +6 -0
- package/dist/response-system/modes/index.js +7 -0
- package/dist/response-system/modes/single-session/index.js +72 -0
- package/dist/response-system/queues/fifo-queue.js +44 -0
- package/dist/response-system/queues/index.js +6 -0
- package/dist/response-system/queues/lifo-queue.js +42 -0
- package/dist/response-system/queues/priority-queue.js +63 -0
- package/dist/response-system/registry.js +97 -0
- package/dist/response-system/resolver.js +37 -0
- package/dist/response-system/selector.js +23 -0
- package/dist/response-system/types.js +7 -0
- package/dist/stats/billing.js +163 -0
- package/dist/stats/budget.js +93 -0
- package/dist/stats/db.js +403 -0
- package/dist/stats/eck-vars.js +89 -0
- package/dist/stats/index.js +11 -0
- package/dist/stats/normalizer.js +80 -0
- package/dist/stats/price-resolver.js +138 -0
- package/dist/stats/query.js +763 -0
- package/dist/stats/role-budget.js +168 -0
- package/dist/stats/writer.js +151 -0
- package/dist/trigger/anomaly-store.js +258 -0
- package/dist/trigger/audit.js +152 -0
- package/dist/trigger/event-source.js +119 -0
- package/dist/trigger/feedback.js +685 -0
- package/dist/trigger/history.js +290 -0
- package/dist/trigger/manager.js +294 -0
- package/dist/trigger/parser.js +595 -0
- package/dist/trigger/patch.js +155 -0
- package/dist/trigger/scheduler.js +1602 -0
- package/dist/trigger/script-executor.js +155 -0
- package/dist/trigger/state.js +145 -0
- package/dist/trigger/types.js +1 -0
- package/dist/trigger/validation.js +634 -0
- package/dist/types.js +12 -0
- package/dist/utils/aid-bind.js +313 -0
- package/dist/utils/atomic-write.js +95 -0
- package/dist/utils/avatar-upload.js +123 -0
- package/dist/utils/cross-platform.js +297 -0
- package/dist/utils/ecweb-utils.js +73 -0
- package/dist/utils/error-dict.json +153 -0
- package/dist/utils/error-utils.js +349 -0
- package/dist/utils/instance-registry.js +444 -0
- package/dist/utils/locale.js +21 -0
- package/dist/utils/log-writer.js +270 -0
- package/dist/utils/logger.js +89 -0
- package/dist/utils/markdown-to-plain-text.js +20 -0
- package/dist/utils/media-cache.js +274 -0
- package/dist/utils/model-prices.jsonl +20 -0
- package/dist/utils/npm-ops.js +210 -0
- package/dist/utils/process-introspect.js +133 -0
- package/dist/utils/process-tree-stats.js +271 -0
- package/dist/utils/project-path.js +74 -0
- package/dist/utils/restart-safety.js +31 -0
- package/dist/utils/stats.js +410 -0
- package/dist/utils/system-memory.js +62 -0
- package/dist/utils/tool-summary.js +284 -0
- package/dist/utils/welcome.js +268 -0
- package/kits/docs/GUIDE.md +20 -0
- package/kits/docs/INDEX.md +66 -0
- package/kits/docs/aun/CHEATSHEET.md +19 -0
- package/kits/docs/aun/SYNC_PROTOCOL.md +15 -0
- package/kits/docs/channels/aun.md +65 -0
- package/kits/docs/channels/feishu.md +56 -0
- package/kits/docs/context-assembly.md +366 -0
- package/kits/docs/eck_templates/GUIDE.template.md +22 -0
- package/kits/docs/eck_templates/INDEX.template.md +28 -0
- package/kits/docs/eck_templates/path-registry.template.md +33 -0
- package/kits/docs/eck_templates/runtime.template.md +19 -0
- package/kits/docs/evolcore/INDEX.md +68 -0
- package/kits/docs/evolcore/agent.md +77 -0
- package/kits/docs/evolcore/aid.md +52 -0
- package/kits/docs/evolcore/config.md +149 -0
- package/kits/docs/evolcore/contact.md +57 -0
- package/kits/docs/evolcore/ctl.md +46 -0
- package/kits/docs/evolcore/event.md +216 -0
- package/kits/docs/evolcore/fs-architecture.md +1215 -0
- package/kits/docs/evolcore/fs.md +110 -0
- package/kits/docs/evolcore/group-fs.md +17 -0
- package/kits/docs/evolcore/group-rules.md +226 -0
- package/kits/docs/evolcore/group.md +150 -0
- package/kits/docs/evolcore/model.md +50 -0
- package/kits/docs/evolcore/msg.md +136 -0
- package/kits/docs/evolcore/response.md +75 -0
- package/kits/docs/evolcore/rpc.md +37 -0
- package/kits/docs/evolcore/self-summary.md +29 -0
- package/kits/docs/evolcore/stats.md +83 -0
- package/kits/docs/evolcore/storage.md +50 -0
- package/kits/docs/evolcore/trigger.md +539 -0
- package/kits/docs/identity/AID_PROFILE_SPEC.md +26 -0
- package/kits/docs/identity/PATH_OPS.md +16 -0
- package/kits/docs/identity/ROLE_DETAIL.md +23 -0
- package/kits/docs/identity/identity-tools.md +26 -0
- package/kits/docs/path-registry.md +43 -0
- package/kits/docs/prompt-loading-architecture.md +266 -0
- package/kits/docs/venues/aun-group.md +45 -0
- package/kits/docs/venues/aun-private.md +10 -0
- package/kits/docs/venues/client-desktop.md +10 -0
- package/kits/docs/venues/client-mobile.md +10 -0
- package/kits/docs/venues/feishu-group.md +13 -0
- package/kits/docs/venues/feishu-private.md +9 -0
- package/kits/docs/venues/group.md +25 -0
- package/kits/docs/venues/private.md +10 -0
- package/kits/eck_manifest.auxiliary.json +43 -0
- package/kits/eck_manifest.json +203 -0
- package/kits/eck_message_manifest.json +63 -0
- package/kits/migrations/README-role-config-v4.md +32 -0
- package/kits/migrations/migrate-contact-book-v2.mjs +747 -0
- package/kits/migrations/migrate-role-config-v4.mjs +623 -0
- package/kits/migrations/migrate-role-config-v5.mjs +346 -0
- package/kits/migrations/rename-config-file.mjs +99 -0
- package/kits/rules/01-overview.md +142 -0
- package/kits/rules/02-navigation.md +76 -0
- package/kits/rules/03-identity.md +34 -0
- package/kits/rules/04-relation.md +59 -0
- package/kits/rules/05-venue.md +44 -0
- package/kits/rules/06-channel.md +59 -0
- package/kits/schemas/_meta.json +32 -0
- package/kits/schemas/agent-config.schema.1.json +177 -0
- package/kits/schemas/agent-config.schema.2.json +239 -0
- package/kits/schemas/agent-config.schema.3.json +119 -0
- package/kits/schemas/agent-config.schema.4.json +208 -0
- package/kits/schemas/agent-config.schema.5.json +326 -0
- package/kits/schemas/agent-config.schema.6.json +322 -0
- package/kits/schemas/contact-book.schema.1.json +36 -0
- package/kits/schemas/contact-book.schema.2.json +43 -0
- package/kits/schemas/daemon.schema.1.json +90 -0
- package/kits/schemas/defaults.schema.1.json +81 -0
- package/kits/schemas/menu-exec-schema-commands.md +208 -0
- package/kits/schemas/migrations/README.md +28 -0
- package/kits/schemas/relation-config.schema.1.json +158 -0
- package/kits/schemas/relation-config.schema.2.json +73 -0
- package/kits/schemas/relation-config.schema.3.json +50 -0
- package/kits/schemas/relation-config.schema.4.json +47 -0
- package/kits/schemas/relation-config.schema.5.json +47 -0
- package/kits/schemas/role-config.schema.1.json +201 -0
- package/kits/schemas/role-registry.schema.1.json +35 -0
- package/kits/schemas/single-session.schema.1.json +31 -0
- package/kits/templates/bootstrap-welcome.md +15 -0
- package/kits/templates/message-fragments/handoff-request-to-target.md +13 -0
- package/kits/templates/message-fragments/handoff-response-to-origin.md +10 -0
- package/kits/templates/message-fragments/inject-default.md +2 -0
- package/kits/templates/message-fragments/item.md +2 -0
- package/kits/templates/roles/admin.json +9 -0
- package/kits/templates/roles/member.json +41 -0
- package/kits/templates/roles/owner.json +9 -0
- package/kits/templates/roles/visitor.json +40 -0
- package/kits/templates/system-fragments/baseagent.md +14 -0
- package/kits/templates/system-fragments/bootstrap.md +16 -0
- package/kits/templates/system-fragments/channel.md +48 -0
- package/kits/templates/system-fragments/commands.md +28 -0
- package/kits/templates/system-fragments/identity.md +11 -0
- package/kits/templates/system-fragments/relation.md +19 -0
- package/kits/templates/system-fragments/session.md +53 -0
- package/kits/templates/system-fragments/venue.md +31 -0
- package/package.json +50 -15
- package/dist/index.d.ts +0 -7
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,1623 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { ConfigTarget, read, resolveEffectiveWithSources, write } from '../../config/config-manager.js';
|
|
5
|
+
import { getBuiltinRolesConfig, getRoleDefinition, isValidUserRoleName, readRolesConfig, } from '../../config/roles.js';
|
|
6
|
+
import { BUILTIN_USER_ROLES, MANAGEMENT_ROLES, expectedRoleRank, isManagementRole } from '../../config/builtin-roles.js';
|
|
7
|
+
import { checkRoleAccess, isStaticAgentOwner, resolvePeerRoleDetail } from '../../config/peer-role-resolver.js';
|
|
8
|
+
import { relationFieldWriteOperation, resolveRoleFieldPermission, } from '../../config/config-field-policy.js';
|
|
9
|
+
import { evaluateRoleOperationCapability } from '../auth/operation-authorizer.js';
|
|
10
|
+
import { listOperations } from '../auth/operation-catalog.js';
|
|
11
|
+
import { auditRoleMutation } from '../auth/authorization-audit.js';
|
|
12
|
+
import { formatPeerKey, parsePeerKey } from '../relation/peer-identity.js';
|
|
13
|
+
import { agentDir, agentRoleConfig, agentRolesIndex, agentRelationConfig, agentRelationsDir, resolvePaths } from '../../paths.js';
|
|
14
|
+
import { atomicReadJson, atomicWrite, atomicWriteJson } from '../../utils/atomic-write.js';
|
|
15
|
+
import { fileCache } from '../daemon-file-cache.js';
|
|
16
|
+
import { currentVersion } from '../../config/schema-registry.js';
|
|
17
|
+
import { loadSchema } from '../../config/schema-registry.js';
|
|
18
|
+
import { rolePermissionFields, rolePermissionFieldOptions, rolePermissionFieldPatterns, validateRoleConfig, } from '../../config/role-schema.js';
|
|
19
|
+
import { groupInfo, groupList } from '../../aun/msg/group.js';
|
|
20
|
+
import { aidLookup, isValidAid } from '../../aun/aid/identity.js';
|
|
21
|
+
import { isExplicitGroupId } from '../../aun/group-identity.js';
|
|
22
|
+
import { readJsonFile, readLastJsonlLine, scanChatDirs, scanMetaFiles } from '../session/session-fs-store.js';
|
|
23
|
+
import { constrainResolvedModelForRole } from '../model/model-permission.js';
|
|
24
|
+
import { resolveRuntimePermissionMode, resolveRuntimeStringField } from '../role/runtime-policy.js';
|
|
25
|
+
import { readRoleDefinition, readRoleRegistry, registerRole, writeRoleDefinition, writeRoleRegistry, } from '../../config/role-service.js';
|
|
26
|
+
import { clearRoleStoreCache, readRoleStore, roleRegistryExists } from '../../config/role-store.js';
|
|
27
|
+
const ROLE_MENU_SCHEMA_VERSION = 1;
|
|
28
|
+
const ROLE_UPDATE_MAX_BYTES = 1024 * 1024;
|
|
29
|
+
const DEFAULT_PAGE_SIZE = 50;
|
|
30
|
+
const MAX_PAGE_SIZE = 200;
|
|
31
|
+
const ROLE_JOURNAL_FILE = 'role-menu-mutation.json';
|
|
32
|
+
const DEFAULT_DIRECTORY = {
|
|
33
|
+
lookupAid: aidLookup,
|
|
34
|
+
getGroup: groupInfo,
|
|
35
|
+
listGroups: groupList,
|
|
36
|
+
};
|
|
37
|
+
const mutationTails = new Map();
|
|
38
|
+
export class RoleMenuError extends Error {
|
|
39
|
+
code;
|
|
40
|
+
data;
|
|
41
|
+
constructor(code, message, data) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.data = data;
|
|
45
|
+
this.name = 'RoleMenuError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function roleMenuOperation(kind, args, value) {
|
|
49
|
+
if (kind === 'options')
|
|
50
|
+
return 'role.policy.read';
|
|
51
|
+
if (kind === 'query') {
|
|
52
|
+
return ['targets', 'assignments', 'target'].includes(String(args?.view))
|
|
53
|
+
? 'role.relation.read'
|
|
54
|
+
: 'role.policy.read';
|
|
55
|
+
}
|
|
56
|
+
if (kind === 'action')
|
|
57
|
+
return 'role.policy.write';
|
|
58
|
+
if (args?.resource !== 'assignment' && args?.resource !== 'admin')
|
|
59
|
+
return 'role.policy.write';
|
|
60
|
+
if (args?.resource === 'admin')
|
|
61
|
+
return 'role.assign';
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(value ?? '') === null ? 'role.revoke' : 'role.assign';
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return 'role.assign';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function roleMenuOptions(context, args) {
|
|
70
|
+
assertRequest(context, args);
|
|
71
|
+
await recoverRoleMutation(context.self);
|
|
72
|
+
assertRoleDomainReady(context.self);
|
|
73
|
+
const option = stringArg(args?.option);
|
|
74
|
+
if (option === 'roles')
|
|
75
|
+
return roleItems(context.self);
|
|
76
|
+
if (option === 'operations') {
|
|
77
|
+
return listOperations().map(operation => ({
|
|
78
|
+
value: operation.id,
|
|
79
|
+
label: operationLabel(operation.id),
|
|
80
|
+
category: operation.category,
|
|
81
|
+
dangerous: operation.dangerous,
|
|
82
|
+
defaultScopes: operation.defaultScopes,
|
|
83
|
+
description: operation.description,
|
|
84
|
+
sources: operation.sources ?? [],
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
if (option === 'permission-fields') {
|
|
88
|
+
return permissionFieldsForContext(context).map(field => ({
|
|
89
|
+
...field,
|
|
90
|
+
supportsDefault: true,
|
|
91
|
+
supportsAllowOverride: field.value !== 'permissionMode',
|
|
92
|
+
supportsAllowedValues: field.valueType === 'enum',
|
|
93
|
+
supportsAllowedModels: field.valueType === 'model',
|
|
94
|
+
runtimeApplied: field.runtimeApplied,
|
|
95
|
+
...(relationFieldWriteOperation(field.value)
|
|
96
|
+
? { operation: relationFieldWriteOperation(field.value) }
|
|
97
|
+
: {}),
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
if (option === 'permission-values')
|
|
101
|
+
return permissionValueOptions(context, args);
|
|
102
|
+
throw roleError('NOT_SUPPORTED', 'The requested Role option is not supported', context.self, {
|
|
103
|
+
option: option || null,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
export async function roleMenuQuery(context, args) {
|
|
107
|
+
assertRequest(context, args);
|
|
108
|
+
await recoverRoleMutation(context.self);
|
|
109
|
+
assertRoleDomainReady(context.self);
|
|
110
|
+
const view = stringArg(args?.view);
|
|
111
|
+
const agent = readAgent(context.self);
|
|
112
|
+
const policyRevision = revision(policyDocument(agent));
|
|
113
|
+
if (view === 'capabilities') {
|
|
114
|
+
const hasAunDirectory = context.aunAvailable !== false;
|
|
115
|
+
return {
|
|
116
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION,
|
|
117
|
+
self: context.self,
|
|
118
|
+
capabilities: {
|
|
119
|
+
roleList: true, roleRead: true, roleWrite: true, roleCreate: true, roleDelete: true, roleReset: true,
|
|
120
|
+
defaultRoleRead: true, defaultRoleWrite: true, permissionFields: true, permissionValues: true,
|
|
121
|
+
operations: true, privateTargetsList: true, joinedGroupsList: hasAunDirectory, assignmentInventory: true,
|
|
122
|
+
relationSnapshot: hasAunDirectory, privateRoleAssign: hasAunDirectory, groupRoleAssign: hasAunDirectory,
|
|
123
|
+
deleteWithReplacement: true, jsonStringUpdateValue: true,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (view === 'defaults') {
|
|
128
|
+
return {
|
|
129
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION,
|
|
130
|
+
self: context.self,
|
|
131
|
+
policyRevision,
|
|
132
|
+
defaultRoles: effectiveDefaultRoles(context.self),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (view === 'role') {
|
|
136
|
+
const roleId = requiredRoleId(args, context.self);
|
|
137
|
+
const definition = getRoleDefinition(roleId, context.self);
|
|
138
|
+
if (!definition)
|
|
139
|
+
throw roleNotFound(context.self, roleId);
|
|
140
|
+
return {
|
|
141
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION,
|
|
142
|
+
self: context.self,
|
|
143
|
+
policyRevision,
|
|
144
|
+
roleId,
|
|
145
|
+
meta: roleMeta(context.self, roleId),
|
|
146
|
+
definition: clone(definition),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (view === 'assignments')
|
|
150
|
+
return assignmentInventory(context, args, policyRevision);
|
|
151
|
+
if (view === 'target')
|
|
152
|
+
return targetSnapshot(context, args, policyRevision);
|
|
153
|
+
if (view === 'targets')
|
|
154
|
+
return targetList(context, args);
|
|
155
|
+
throw roleError('NOT_SUPPORTED', 'The requested Role view is not supported', context.self, {
|
|
156
|
+
view: view || null,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
export async function roleMenuUpdate(context, args, rawValue) {
|
|
160
|
+
assertRequest(context, args);
|
|
161
|
+
const value = parseUpdateValue(rawValue, context.self);
|
|
162
|
+
return withAgentMutation(context.self, async () => {
|
|
163
|
+
recoverRoleMutationSync(context.self);
|
|
164
|
+
assertRoleDomainReady(context.self);
|
|
165
|
+
const before = mutationAuditSnapshot(context.self, args);
|
|
166
|
+
const resource = stringArg(args?.resource);
|
|
167
|
+
let result;
|
|
168
|
+
if (resource === 'definition') {
|
|
169
|
+
assertRolePolicyMutationTarget(context, args);
|
|
170
|
+
result = updateDefinition(context.self, args, value);
|
|
171
|
+
}
|
|
172
|
+
else if (resource === 'defaultRoles')
|
|
173
|
+
result = updateDefaultRoles(context, args, value);
|
|
174
|
+
else if (resource === 'assignment')
|
|
175
|
+
result = await updateAssignment(context, args, value);
|
|
176
|
+
else if (resource === 'admin')
|
|
177
|
+
result = await updateAdminAssignment(context, args, value);
|
|
178
|
+
else
|
|
179
|
+
throw roleError('NOT_SUPPORTED', 'The requested Role resource is not supported', context.self, { resource: resource || null });
|
|
180
|
+
auditMutation(context, args, before, result, resource === 'defaultRoles' ? 'default-update' : resource === 'assignment' ? 'assignment' : 'update');
|
|
181
|
+
return result;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
export async function roleMenuAction(context, action, args) {
|
|
185
|
+
assertRequest(context, args);
|
|
186
|
+
return withAgentMutation(context.self, async () => {
|
|
187
|
+
recoverRoleMutationSync(context.self);
|
|
188
|
+
assertRoleDomainReady(context.self);
|
|
189
|
+
const before = mutationAuditSnapshot(context.self, args, action === 'delete');
|
|
190
|
+
let result;
|
|
191
|
+
if (action === 'create')
|
|
192
|
+
result = createRole(context.self, args);
|
|
193
|
+
else if (action === 'reset') {
|
|
194
|
+
assertRolePolicyMutationTarget(context, args);
|
|
195
|
+
result = resetRole(context.self, args);
|
|
196
|
+
}
|
|
197
|
+
else if (action === 'delete')
|
|
198
|
+
result = await deleteRole(context, args);
|
|
199
|
+
else
|
|
200
|
+
throw roleError('NOT_SUPPORTED', 'The requested Role action is not supported', context.self, { action });
|
|
201
|
+
auditMutation(context, args, before, result, action);
|
|
202
|
+
return result;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
function mutationAuditSnapshot(self, args, includeRoleRelations = false) {
|
|
206
|
+
const files = {};
|
|
207
|
+
const roleId = stringArg(args?.roleId);
|
|
208
|
+
const filePaths = [agentRolesIndex(self), roleId ? agentRoleConfig(self, roleId) : null].filter(Boolean);
|
|
209
|
+
for (const file of filePaths)
|
|
210
|
+
files[file] = readAuditValue(file);
|
|
211
|
+
if (includeRoleRelations && roleId) {
|
|
212
|
+
for (const file of walkRelationConfigFiles(agentRelationsDir(self))) {
|
|
213
|
+
const value = readAuditJson(file);
|
|
214
|
+
if (isObject(value) && value.role === roleId)
|
|
215
|
+
files[file] = value;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const target = args?.target;
|
|
219
|
+
if (target && typeof target === 'object') {
|
|
220
|
+
try {
|
|
221
|
+
const normalized = normalizeTarget(target, self);
|
|
222
|
+
const relationFile = agentRelationConfig(self, normalized.peerKey);
|
|
223
|
+
files[relationFile] = readAuditJson(relationFile);
|
|
224
|
+
return { files, relation: normalized.peerKey };
|
|
225
|
+
}
|
|
226
|
+
catch { }
|
|
227
|
+
}
|
|
228
|
+
return { files };
|
|
229
|
+
}
|
|
230
|
+
function walkRelationConfigFiles(dir) {
|
|
231
|
+
if (!fs.existsSync(dir))
|
|
232
|
+
return [];
|
|
233
|
+
const files = [];
|
|
234
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
235
|
+
const full = path.join(dir, entry.name);
|
|
236
|
+
if (entry.isDirectory())
|
|
237
|
+
files.push(...walkRelationConfigFiles(full));
|
|
238
|
+
else if (entry.isFile() && entry.name === 'config.json')
|
|
239
|
+
files.push(full);
|
|
240
|
+
}
|
|
241
|
+
return files;
|
|
242
|
+
}
|
|
243
|
+
function readAuditJson(file) {
|
|
244
|
+
try {
|
|
245
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function readAuditValue(file) {
|
|
252
|
+
return readAuditJson(file);
|
|
253
|
+
}
|
|
254
|
+
function auditMutation(context, args, before, result, operation) {
|
|
255
|
+
if (result?.changed === false)
|
|
256
|
+
return;
|
|
257
|
+
const afterFiles = {};
|
|
258
|
+
for (const file of Object.keys(before.files))
|
|
259
|
+
afterFiles[file] = readAuditValue(file);
|
|
260
|
+
const changedFiles = Object.keys(before.files)
|
|
261
|
+
.filter(file => stableStringify(before.files[file]) !== stableStringify(afterFiles[file]));
|
|
262
|
+
const relationFile = changedFiles.find(file => isPathInside(file, agentRelationsDir(context.self)));
|
|
263
|
+
const beforeRelation = relationFile ? before.files[relationFile] : undefined;
|
|
264
|
+
const afterRelation = relationFile ? afterFiles[relationFile] : undefined;
|
|
265
|
+
const roleId = (stringArg(args?.roleId) || undefined)
|
|
266
|
+
?? result?.roleId
|
|
267
|
+
?? result?.assignment?.explicitRole
|
|
268
|
+
?? result?.assignment?.explicitDefaultRole
|
|
269
|
+
?? (isObject(afterRelation) && typeof afterRelation.role === 'string' ? afterRelation.role : undefined)
|
|
270
|
+
?? (isObject(beforeRelation) && typeof beforeRelation.role === 'string' ? beforeRelation.role : undefined);
|
|
271
|
+
const primaryFile = before.relation
|
|
272
|
+
? relationFile
|
|
273
|
+
: roleId
|
|
274
|
+
? changedFiles.find(file => path.resolve(file) === path.resolve(agentRoleConfig(context.self, roleId))) ?? changedFiles[0]
|
|
275
|
+
: changedFiles.find(file => path.resolve(file) === path.resolve(agentRolesIndex(context.self))) ?? changedFiles[0];
|
|
276
|
+
auditRoleMutation({
|
|
277
|
+
operation,
|
|
278
|
+
actor: context.actorAid ?? context.actorRole,
|
|
279
|
+
selfAid: context.self,
|
|
280
|
+
roleId,
|
|
281
|
+
relation: before.relation,
|
|
282
|
+
oldValue: primaryFile ? before.files[primaryFile] : undefined,
|
|
283
|
+
newValue: primaryFile ? afterFiles[primaryFile] : undefined,
|
|
284
|
+
files: changedFiles.map(file => ({ file, before: before.files[file], after: afterFiles[file] })),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
function isPathInside(file, directory) {
|
|
288
|
+
const relative = path.relative(path.resolve(directory), path.resolve(file));
|
|
289
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
290
|
+
}
|
|
291
|
+
export function recoverRoleMutationSync(self) {
|
|
292
|
+
const journalFile = roleJournalPath(self);
|
|
293
|
+
const journal = atomicReadJson(journalFile);
|
|
294
|
+
if (!journal)
|
|
295
|
+
return;
|
|
296
|
+
validateJournal(self, journal);
|
|
297
|
+
if (journal.state === 'prepared') {
|
|
298
|
+
for (const change of [...journal.changes].reverse())
|
|
299
|
+
applyFileContent(change.file, change.before);
|
|
300
|
+
}
|
|
301
|
+
removeJournalFiles(journalFile);
|
|
302
|
+
invalidateChanges(journal.changes);
|
|
303
|
+
}
|
|
304
|
+
export function recoverAllRoleMutationsSync() {
|
|
305
|
+
const agentsDir = resolvePaths().agentsDir;
|
|
306
|
+
if (!fs.existsSync(agentsDir))
|
|
307
|
+
return;
|
|
308
|
+
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
|
|
309
|
+
if (!entry.isDirectory() || !isValidAid(entry.name))
|
|
310
|
+
continue;
|
|
311
|
+
if (!fs.existsSync(roleJournalPath(entry.name)))
|
|
312
|
+
continue;
|
|
313
|
+
recoverRoleMutationSync(entry.name);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async function recoverRoleMutation(self) {
|
|
317
|
+
return withAgentMutation(self, async () => recoverRoleMutationSync(self));
|
|
318
|
+
}
|
|
319
|
+
function assertRequest(context, args) {
|
|
320
|
+
const requestedSelf = stringArg(args?.self);
|
|
321
|
+
if (!requestedSelf || requestedSelf !== context.self) {
|
|
322
|
+
throw roleError('PERMISSION_DENIED', 'args.self does not match the receiving Agent', context.self, {
|
|
323
|
+
kind: 'agent_scope_mismatch',
|
|
324
|
+
requestedSelf: requestedSelf || null,
|
|
325
|
+
receivingSelf: context.self,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (context.actorRole !== 'owner' && context.actorRole !== 'admin') {
|
|
329
|
+
throw roleError('PERMISSION_DENIED', 'Role management requires Agent owner or admin', context.self, {
|
|
330
|
+
kind: 'role_permission_denied',
|
|
331
|
+
actorRole: context.actorRole,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function assertRolePolicyMutationTarget(context, args) {
|
|
336
|
+
const roleId = requiredRoleId(args, context.self);
|
|
337
|
+
if (context.actorRole === 'admin' && roleId === 'owner') {
|
|
338
|
+
throw roleError('PERMISSION_DENIED', 'Agent admin cannot modify the owner role policy', context.self, {
|
|
339
|
+
kind: 'role_hierarchy_violation',
|
|
340
|
+
actorRole: context.actorRole,
|
|
341
|
+
targetRole: roleId,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function assertRoleDomainReady(self) {
|
|
346
|
+
if (!roleRegistryExists(self)) {
|
|
347
|
+
throw roleError('CONFLICT', 'Standalone role registry is required before using Role management', self, {
|
|
348
|
+
kind: 'role_registry_missing',
|
|
349
|
+
file: path.relative(resolvePaths().root, agentRolesIndex(self)),
|
|
350
|
+
migration: 'kits/migrations/migrate-role-config-v5.mjs --apply',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
const agent = readAgent(self);
|
|
354
|
+
if (Object.prototype.hasOwnProperty.call(agent, 'roles')) {
|
|
355
|
+
throw roleError('CONFLICT', 'Agent contains both legacy roles and the standalone role registry', self, {
|
|
356
|
+
kind: 'role_registry_ambiguous',
|
|
357
|
+
migration: 'kits/migrations/migrate-role-config-v5.mjs --apply',
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const invalid = readRoleStore(self).diagnostics.find(issue => issue.code === 'INVALID_ROLE_REGISTRY');
|
|
361
|
+
if (invalid) {
|
|
362
|
+
throw roleError('CONFLICT', 'Standalone role registry is invalid', self, {
|
|
363
|
+
kind: 'role_registry_invalid',
|
|
364
|
+
file: invalid.file ?? path.relative(resolvePaths().root, agentRolesIndex(self)),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function roleItems(self) {
|
|
369
|
+
const roles = readRolesConfig(self).roles;
|
|
370
|
+
const userItems = Object.keys(roles).sort((a, b) => {
|
|
371
|
+
const ai = BUILTIN_USER_ROLES.indexOf(a);
|
|
372
|
+
const bi = BUILTIN_USER_ROLES.indexOf(b);
|
|
373
|
+
if (ai >= 0 || bi >= 0)
|
|
374
|
+
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
|
|
375
|
+
return a.localeCompare(b);
|
|
376
|
+
}).map(roleId => ({
|
|
377
|
+
value: roleId,
|
|
378
|
+
label: title(roleId),
|
|
379
|
+
...roleMeta(self, roleId),
|
|
380
|
+
}));
|
|
381
|
+
const managementItems = MANAGEMENT_ROLES.map(roleId => ({
|
|
382
|
+
value: roleId,
|
|
383
|
+
label: title(roleId),
|
|
384
|
+
...roleMeta(self, roleId),
|
|
385
|
+
}));
|
|
386
|
+
return [...userItems, ...managementItems];
|
|
387
|
+
}
|
|
388
|
+
async function permissionValueOptions(context, args) {
|
|
389
|
+
const field = stringArg(args?.field);
|
|
390
|
+
const descriptor = permissionFieldsForContext(context).find(item => item.value === field);
|
|
391
|
+
if (!descriptor || !descriptor.hasOptions) {
|
|
392
|
+
throw roleError('NOT_SUPPORTED', 'The requested permission field does not provide option values', context.self, {
|
|
393
|
+
field: field || null,
|
|
394
|
+
hasOptions: descriptor?.hasOptions ?? false,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
const staticOptions = rolePermissionFieldOptions(descriptor);
|
|
398
|
+
if (staticOptions.length)
|
|
399
|
+
return clone(staticOptions);
|
|
400
|
+
const patterns = rolePermissionFieldPatterns(descriptor);
|
|
401
|
+
const baseagent = field.split('.')[1];
|
|
402
|
+
let models = [];
|
|
403
|
+
try {
|
|
404
|
+
models = context.listModels ? await context.listModels(baseagent) : [];
|
|
405
|
+
}
|
|
406
|
+
catch { }
|
|
407
|
+
return [
|
|
408
|
+
...patterns,
|
|
409
|
+
...Array.from(new Set(models.filter(value => typeof value === 'string' && value.trim()))).map(value => ({
|
|
410
|
+
value,
|
|
411
|
+
label: value,
|
|
412
|
+
kind: 'model',
|
|
413
|
+
isAlias: false,
|
|
414
|
+
})),
|
|
415
|
+
];
|
|
416
|
+
}
|
|
417
|
+
function permissionFieldsForContext(context) {
|
|
418
|
+
const fields = rolePermissionFields();
|
|
419
|
+
if (!context.availableBaseagents?.length)
|
|
420
|
+
return fields;
|
|
421
|
+
const available = new Set(context.availableBaseagents);
|
|
422
|
+
return fields.filter(field => {
|
|
423
|
+
const match = /^baseagents\.([^.]+)\./.exec(field.value);
|
|
424
|
+
return !match || available.has(match[1]);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
function updateDefinition(self, args, value) {
|
|
428
|
+
if (!isObject(value))
|
|
429
|
+
throw invalidJsonType(self, 'definition', 'object');
|
|
430
|
+
const roleId = requiredRoleId(args, self);
|
|
431
|
+
const current = getRoleDefinition(roleId, self);
|
|
432
|
+
if (!current)
|
|
433
|
+
throw roleNotFound(self, roleId);
|
|
434
|
+
assertPolicyRevision(self, args);
|
|
435
|
+
validateRemove(args?.remove, self, roleId);
|
|
436
|
+
const nextDefinition = mergeDefinition(current, value, args?.remove, self, roleId);
|
|
437
|
+
const errors = validateRoleConfig(nextDefinition);
|
|
438
|
+
if (errors.length)
|
|
439
|
+
throw validationFailed(self, roleId, errors);
|
|
440
|
+
const currentDefinition = readRoleDefinition(self, roleId);
|
|
441
|
+
const changed = stableStringify(currentDefinition) !== stableStringify(nextDefinition);
|
|
442
|
+
if (changed)
|
|
443
|
+
writeRoleDefinition(self, roleId, nextDefinition);
|
|
444
|
+
const nextAgent = readAgent(self);
|
|
445
|
+
return {
|
|
446
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION,
|
|
447
|
+
self,
|
|
448
|
+
resource: 'definition',
|
|
449
|
+
roleId,
|
|
450
|
+
changed,
|
|
451
|
+
policyRevision: revision(policyDocument(nextAgent)),
|
|
452
|
+
definition: clone(getRoleDefinition(roleId, self) ?? nextDefinition),
|
|
453
|
+
...(args?.remove ? { removed: normalizeRemove(args.remove) } : {}),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function updateDefaultRoles(context, args, value) {
|
|
457
|
+
const self = context.self;
|
|
458
|
+
if (!isObject(value))
|
|
459
|
+
throw invalidJsonType(self, 'defaultRoles', 'object');
|
|
460
|
+
assertOnlyKeys(value, ['private', 'group'], 'defaultRoles', self);
|
|
461
|
+
if (!Object.prototype.hasOwnProperty.call(value, 'private') || !Object.prototype.hasOwnProperty.call(value, 'group')) {
|
|
462
|
+
throw validationFailed(self, undefined, [{ field: 'defaultRoles', code: 'MISSING_FIELD', message: 'private and group are required' }]);
|
|
463
|
+
}
|
|
464
|
+
const nextDefaults = {
|
|
465
|
+
private: validateAssignableRole(value.private, self, 'defaultRoles.private'),
|
|
466
|
+
group: validateAssignableRole(value.group, self, 'defaultRoles.group'),
|
|
467
|
+
};
|
|
468
|
+
assertPolicyRevision(self, args);
|
|
469
|
+
const registry = readRoleRegistry(self);
|
|
470
|
+
const changesAdminDefault = ['private', 'group'].some(scene => registry.defaultRoles[scene] !== nextDefaults[scene]
|
|
471
|
+
&& (registry.defaultRoles[scene] === 'admin' || nextDefaults[scene] === 'admin'));
|
|
472
|
+
if (context.actorRole !== 'owner' && changesAdminDefault) {
|
|
473
|
+
throw roleError('NOT_ALLOWED', 'Only an owner can set or replace an admin default role', self, {
|
|
474
|
+
kind: 'admin_owner_only',
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
const nextRegistry = { ...registry, defaultRoles: nextDefaults };
|
|
478
|
+
const changed = stableStringify(registry) !== stableStringify(nextRegistry);
|
|
479
|
+
if (changed)
|
|
480
|
+
writeRoleRegistry(self, nextRegistry);
|
|
481
|
+
const nextAgent = readAgent(self);
|
|
482
|
+
return {
|
|
483
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION,
|
|
484
|
+
self,
|
|
485
|
+
resource: 'defaultRoles',
|
|
486
|
+
changed,
|
|
487
|
+
policyRevision: revision(policyDocument(nextAgent)),
|
|
488
|
+
defaultRoles: nextDefaults,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
async function updateAssignment(context, args, value) {
|
|
492
|
+
const self = context.self;
|
|
493
|
+
if (value !== null && typeof value !== 'string')
|
|
494
|
+
throw invalidJsonType(self, 'assignment', 'string or null');
|
|
495
|
+
assertPolicyRevision(self, args);
|
|
496
|
+
let target = normalizeTarget(args?.target, self);
|
|
497
|
+
const roleId = value === null ? null : validateAssignableRole(value, self, 'assignment');
|
|
498
|
+
if (roleId !== null)
|
|
499
|
+
target = await validateTargetExists(context, target);
|
|
500
|
+
const targetAid = target.kind === 'private' ? target.peerAid : null;
|
|
501
|
+
if (targetAid && isStaticAgentOwner(self, targetAid)) {
|
|
502
|
+
throw roleError('NOT_ALLOWED', 'Owner assignments cannot be changed through relations', self, {
|
|
503
|
+
kind: 'owner_protected',
|
|
504
|
+
targetAid,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
const current = readRelation(target.peerKey, self);
|
|
508
|
+
if (context.actorRole !== 'owner' && (roleId === 'admin' || current?.config.role === 'admin')) {
|
|
509
|
+
throw roleError('NOT_ALLOWED', 'Only an owner can grant or revoke admin', self, {
|
|
510
|
+
kind: 'admin_owner_only',
|
|
511
|
+
target: target.peerKey,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
assertRelationRevision(self, args, current?.revision ?? null, target.peerKey);
|
|
515
|
+
if (!current && roleId === null)
|
|
516
|
+
return assignmentResponse(self, target, null, false, null);
|
|
517
|
+
const relation = clone(current?.config ?? { $schema_version: currentVersion('relation-config') });
|
|
518
|
+
relation.targetKind = target.kind;
|
|
519
|
+
if (roleId === null)
|
|
520
|
+
delete relation.role;
|
|
521
|
+
else
|
|
522
|
+
relation.role = roleId;
|
|
523
|
+
const normalized = normalizeRelationForScene(relation, target.kind);
|
|
524
|
+
const changed = stableStringify(current?.config ?? null) !== stableStringify(normalized);
|
|
525
|
+
if (changed)
|
|
526
|
+
write(ConfigTarget.Relation, normalized, { self, peerKey: target.peerKey });
|
|
527
|
+
const nextRevision = changed ? revision(normalized) : current?.revision ?? null;
|
|
528
|
+
return assignmentResponse(self, target, normalized, changed, nextRevision);
|
|
529
|
+
}
|
|
530
|
+
async function updateAdminAssignment(context, args, value) {
|
|
531
|
+
const self = context.self;
|
|
532
|
+
if (context.actorRole !== 'owner') {
|
|
533
|
+
throw roleError('NOT_ALLOWED', 'Only an owner can grant or revoke admin', self, { kind: 'admin_owner_only' });
|
|
534
|
+
}
|
|
535
|
+
let target = normalizeTarget(args?.target, self);
|
|
536
|
+
if (target.kind !== 'private')
|
|
537
|
+
throw invalidTargetValue(self, 'target.kind');
|
|
538
|
+
if (isStaticAgentOwner(self, target.peerAid)) {
|
|
539
|
+
throw roleError('NOT_ALLOWED', 'Owner assignments cannot be changed through contact admin', self, { kind: 'owner_protected' });
|
|
540
|
+
}
|
|
541
|
+
const enabled = value === true || value === 'grant'
|
|
542
|
+
? true
|
|
543
|
+
: value === false || value === null || value === 'revoke'
|
|
544
|
+
? false
|
|
545
|
+
: undefined;
|
|
546
|
+
if (enabled === undefined)
|
|
547
|
+
throw invalidJsonType(self, 'admin', 'boolean, grant, revoke, or null');
|
|
548
|
+
if (enabled)
|
|
549
|
+
target = await validateTargetExists(context, target);
|
|
550
|
+
const current = readRelation(target.peerKey, self);
|
|
551
|
+
assertRelationRevision(self, args, current?.revision ?? null, target.peerKey);
|
|
552
|
+
if (!current && !enabled) {
|
|
553
|
+
return {
|
|
554
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION, self, resource: 'admin', target,
|
|
555
|
+
changed: false, relationRevision: null, admin: false,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
const relation = clone(current?.config ?? { $schema_version: currentVersion('relation-config') });
|
|
559
|
+
relation.targetKind = 'private';
|
|
560
|
+
if (enabled)
|
|
561
|
+
relation.role = 'admin';
|
|
562
|
+
else if (relation.role === 'admin')
|
|
563
|
+
delete relation.role;
|
|
564
|
+
const normalized = normalizeRelationForScene(relation, 'private');
|
|
565
|
+
const changed = stableStringify(current?.config ?? null) !== stableStringify(normalized);
|
|
566
|
+
if (changed)
|
|
567
|
+
write(ConfigTarget.Relation, normalized, { self, peerKey: target.peerKey });
|
|
568
|
+
const nextRevision = changed ? revision(normalized) : current?.revision ?? null;
|
|
569
|
+
return {
|
|
570
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION, self, resource: 'admin', target,
|
|
571
|
+
changed, relationRevision: nextRevision, admin: normalized.role === 'admin',
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
function createRole(self, args) {
|
|
575
|
+
const roleId = requiredRoleId(args, self);
|
|
576
|
+
if (!isValidUserRoleName(roleId)) {
|
|
577
|
+
throw validationFailed(self, roleId, [{ field: 'roleId', code: 'INVALID_VALUE', message: 'roleId must match ^[a-z0-9_-]+$ and cannot be owner/admin' }]);
|
|
578
|
+
}
|
|
579
|
+
if (getRoleDefinition(roleId, self)) {
|
|
580
|
+
throw roleError('CONFLICT', 'Role already exists', self, { kind: 'role_exists', roleId });
|
|
581
|
+
}
|
|
582
|
+
assertPolicyRevision(self, args);
|
|
583
|
+
if (!isObject(args?.definition))
|
|
584
|
+
throw validationFailed(self, roleId, [{ field: 'definition', code: 'INVALID_TYPE', message: 'definition must be an object' }]);
|
|
585
|
+
const definition = clone(args.definition);
|
|
586
|
+
if (definition.rank !== undefined && definition.rank !== expectedRoleRank(roleId)) {
|
|
587
|
+
throw validationFailed(self, roleId, [{ field: 'definition.rank', code: 'INVALID_VALUE', message: 'custom role rank is fixed at 500' }]);
|
|
588
|
+
}
|
|
589
|
+
definition.rank = expectedRoleRank(roleId);
|
|
590
|
+
const errors = validateRoleConfig(definition);
|
|
591
|
+
if (errors.length)
|
|
592
|
+
throw validationFailed(self, roleId, errors);
|
|
593
|
+
registerRole(self, roleId, definition);
|
|
594
|
+
const nextAgent = readAgent(self);
|
|
595
|
+
return {
|
|
596
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION,
|
|
597
|
+
self,
|
|
598
|
+
action: 'create',
|
|
599
|
+
changed: true,
|
|
600
|
+
roleId,
|
|
601
|
+
policyRevision: revision(policyDocument(nextAgent)),
|
|
602
|
+
meta: roleMeta(self, roleId),
|
|
603
|
+
definition,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function resetRole(self, args) {
|
|
607
|
+
const roleId = requiredRoleId(args, self);
|
|
608
|
+
if (!BUILTIN_USER_ROLES.includes(roleId)) {
|
|
609
|
+
if (isManagementRole(roleId))
|
|
610
|
+
return resetManagementRole(self, roleId, args);
|
|
611
|
+
if (!getRoleDefinition(roleId, self))
|
|
612
|
+
throw roleNotFound(self, roleId);
|
|
613
|
+
throw roleError('NOT_ALLOWED', 'Only overridden built-in user roles can be reset', self, {
|
|
614
|
+
kind: 'protected_role', roleId, allowedAction: null,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
assertPolicyRevision(self, args);
|
|
618
|
+
const file = agentRoleConfig(self, roleId);
|
|
619
|
+
const changed = fs.existsSync(file);
|
|
620
|
+
if (changed) {
|
|
621
|
+
fs.rmSync(file, { force: true });
|
|
622
|
+
clearRoleStoreCache(self);
|
|
623
|
+
}
|
|
624
|
+
const nextAgent = readAgent(self);
|
|
625
|
+
return {
|
|
626
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION, self, action: 'reset', changed, roleId,
|
|
627
|
+
policyRevision: revision(policyDocument(nextAgent)),
|
|
628
|
+
meta: roleMeta(self, roleId),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function resetManagementRole(self, roleId, args) {
|
|
632
|
+
assertPolicyRevision(self, args);
|
|
633
|
+
const file = agentRoleConfig(self, roleId);
|
|
634
|
+
const changed = fs.existsSync(file);
|
|
635
|
+
if (changed) {
|
|
636
|
+
fs.rmSync(file, { force: true });
|
|
637
|
+
clearRoleStoreCache(self);
|
|
638
|
+
}
|
|
639
|
+
const nextAgent = readAgent(self);
|
|
640
|
+
return {
|
|
641
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION,
|
|
642
|
+
self,
|
|
643
|
+
action: 'reset',
|
|
644
|
+
changed,
|
|
645
|
+
roleId,
|
|
646
|
+
policyRevision: revision(policyDocument(nextAgent)),
|
|
647
|
+
meta: roleMeta(self, roleId),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
async function deleteRole(context, args) {
|
|
651
|
+
const self = context.self;
|
|
652
|
+
const roleId = requiredRoleId(args, self);
|
|
653
|
+
if ([...MANAGEMENT_ROLES, ...BUILTIN_USER_ROLES].includes(roleId)) {
|
|
654
|
+
throw protectedRole(self, roleId, BUILTIN_USER_ROLES.includes(roleId) ? 'reset' : null);
|
|
655
|
+
}
|
|
656
|
+
const agent = readAgent(self);
|
|
657
|
+
const domainDefinitions = readRoleStore(self).definitions;
|
|
658
|
+
if (!Object.prototype.hasOwnProperty.call(domainDefinitions, roleId)) {
|
|
659
|
+
throw roleNotFound(self, roleId);
|
|
660
|
+
}
|
|
661
|
+
assertPolicyRevision(self, args, agent);
|
|
662
|
+
const hasReplacement = Object.prototype.hasOwnProperty.call(args ?? {}, 'replacementRoleId');
|
|
663
|
+
const replacement = hasReplacement
|
|
664
|
+
? validateAssignableRole(args?.replacementRoleId, self, 'replacementRoleId')
|
|
665
|
+
: undefined;
|
|
666
|
+
if (replacement === 'admin' && context.actorRole !== 'owner') {
|
|
667
|
+
throw roleError('NOT_ALLOWED', 'Only an owner can replace role assignments with admin', self, {
|
|
668
|
+
kind: 'admin_owner_only',
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
if (replacement === roleId) {
|
|
672
|
+
throw validationFailed(self, roleId, [{ field: 'replacementRoleId', code: 'INVALID_VALUE', message: 'replacementRoleId must differ from roleId' }]);
|
|
673
|
+
}
|
|
674
|
+
const relations = listRelations(self, await knownRelationTargets(context));
|
|
675
|
+
const ambiguous = relations.filter(relation => relation.targetKind === 'unknown' && relationReferencesRole(relation.config, roleId));
|
|
676
|
+
if (ambiguous.length) {
|
|
677
|
+
throw roleError('CONFLICT', 'Role references relations whose target type is unknown', self, {
|
|
678
|
+
kind: 'ambiguous_relation_target', roleId, peerKeys: ambiguous.map(relation => relation.peerKey),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
const usage = countRoleUsage(self, relations, roleId);
|
|
682
|
+
const hasUsage = usage.defaultScenes.length > 0 || usage.privateRelations > 0 || usage.groupRelations > 0;
|
|
683
|
+
if (hasUsage && !hasReplacement) {
|
|
684
|
+
throw roleError('CONFLICT', 'Role is still referenced', self, { kind: 'role_in_use', roleId, ...usage });
|
|
685
|
+
}
|
|
686
|
+
const registry = readRoleRegistry(self);
|
|
687
|
+
const nextRegistry = clone(registry);
|
|
688
|
+
nextRegistry.roles = nextRegistry.roles.filter(item => item !== roleId);
|
|
689
|
+
for (const scene of ['private', 'group']) {
|
|
690
|
+
if (nextRegistry.defaultRoles[scene] === roleId)
|
|
691
|
+
nextRegistry.defaultRoles[scene] = replacement ?? null;
|
|
692
|
+
}
|
|
693
|
+
const changes = [
|
|
694
|
+
{
|
|
695
|
+
file: agentRolesIndex(self),
|
|
696
|
+
before: readFileText(agentRolesIndex(self)),
|
|
697
|
+
after: jsonText(nextRegistry),
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
file: agentRoleConfig(self, roleId),
|
|
701
|
+
before: readFileText(agentRoleConfig(self, roleId)),
|
|
702
|
+
after: null,
|
|
703
|
+
},
|
|
704
|
+
];
|
|
705
|
+
appendRoleRelationChanges(self, relations, roleId, replacement, changes);
|
|
706
|
+
validateRoleDomainTransactionChanges(self, nextRegistry, changes);
|
|
707
|
+
commitJournal(self, changes);
|
|
708
|
+
return {
|
|
709
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION, self, action: 'delete', changed: true, roleId,
|
|
710
|
+
replacementRoleId: hasReplacement ? replacement : null,
|
|
711
|
+
policyRevision: revision(policyDocument(readAgent(self))),
|
|
712
|
+
migrated: usage,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
async function assignmentInventory(context, args, policyRevision) {
|
|
716
|
+
const self = context.self;
|
|
717
|
+
const roleFilter = args?.roleId === undefined ? undefined : stringArg(args.roleId);
|
|
718
|
+
if (args?.roleId !== undefined && !roleFilter)
|
|
719
|
+
throw validationFailed(self, undefined, [{ field: 'roleId', code: 'INVALID_VALUE', message: 'roleId must be a non-empty string' }]);
|
|
720
|
+
const items = [];
|
|
721
|
+
if (args?.includeDefaults !== false) {
|
|
722
|
+
const defaults = effectiveDefaultRoles(self);
|
|
723
|
+
for (const scene of ['private', 'group']) {
|
|
724
|
+
const roleId = defaults[scene];
|
|
725
|
+
if (roleId && (!roleFilter || roleFilter === roleId)) {
|
|
726
|
+
items.push({ target: { kind: 'default', scene }, roleId, relationRevision: null });
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
let complete = true;
|
|
731
|
+
for (const relation of listRelations(self, await knownRelationTargets(context))) {
|
|
732
|
+
const assigned = relation.config.role;
|
|
733
|
+
if (typeof assigned === 'string' && (!roleFilter || roleFilter === assigned)) {
|
|
734
|
+
if (relation.targetKind === 'unknown')
|
|
735
|
+
complete = false;
|
|
736
|
+
items.push({
|
|
737
|
+
target: relation.targetKind === 'group'
|
|
738
|
+
? { kind: 'group', groupId: relation.channelId, peerKey: relation.peerKey }
|
|
739
|
+
: relation.targetKind === 'private'
|
|
740
|
+
? { kind: 'private', peerAid: relation.channelId, peerKey: relation.peerKey }
|
|
741
|
+
: { kind: 'unknown', channelId: relation.channelId, peerKey: relation.peerKey },
|
|
742
|
+
roleId: assigned,
|
|
743
|
+
relationRevision: relation.revision,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
items.sort((a, b) => assignmentKey(a).localeCompare(assignmentKey(b)));
|
|
748
|
+
const page = paginate(items, args, self, assignmentKey);
|
|
749
|
+
return {
|
|
750
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION,
|
|
751
|
+
self,
|
|
752
|
+
policyRevision,
|
|
753
|
+
items: page.items,
|
|
754
|
+
nextCursor: page.nextCursor,
|
|
755
|
+
dataSource: {
|
|
756
|
+
kind: 'agent-and-relation-config', authoritative: complete, complete, observedAt: Date.now(),
|
|
757
|
+
...(complete ? {} : { limitation: 'Some legacy relations have no authoritative private/group target kind' }),
|
|
758
|
+
},
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
async function targetSnapshot(context, args, policyRevision) {
|
|
762
|
+
const self = context.self;
|
|
763
|
+
const target = await validateTargetExists(context, normalizeTarget(args?.target, self), { allowAuthenticatedPrivateActor: true });
|
|
764
|
+
const relation = readRelation(target.peerKey, self);
|
|
765
|
+
return {
|
|
766
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION,
|
|
767
|
+
self,
|
|
768
|
+
policyRevision,
|
|
769
|
+
target,
|
|
770
|
+
relationRevision: relation?.revision ?? null,
|
|
771
|
+
...targetRoleView(self, target, relation?.config ?? null),
|
|
772
|
+
relationConfig: relationConfigView(context, target, relation?.config ?? null),
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
async function targetList(context, args) {
|
|
776
|
+
const self = context.self;
|
|
777
|
+
const targetType = stringArg(args?.targetType);
|
|
778
|
+
if (targetType === 'group') {
|
|
779
|
+
assertAunDirectory(context);
|
|
780
|
+
const result = await listJoinedGroups(context);
|
|
781
|
+
const items = result.items.map(group => {
|
|
782
|
+
const peerKey = formatPeerKey('aun', group.group_id);
|
|
783
|
+
const relation = readRelation(peerKey, self);
|
|
784
|
+
const view = targetRoleView(self, { kind: 'group', groupId: group.group_id, peerKey }, relation?.config ?? null);
|
|
785
|
+
return {
|
|
786
|
+
kind: 'group', groupId: group.group_id, peerKey, displayName: group.name,
|
|
787
|
+
status: group.status ?? 'active', memberCount: group.member_count ?? null,
|
|
788
|
+
relationRevision: relation?.revision ?? null,
|
|
789
|
+
explicitDefaultRole: view.assignment.explicitDefaultRole,
|
|
790
|
+
baselineRole: view.baselineRole.roleId,
|
|
791
|
+
baselineRoleSource: view.baselineRole.source,
|
|
792
|
+
};
|
|
793
|
+
});
|
|
794
|
+
items.sort((left, right) => left.peerKey.localeCompare(right.peerKey));
|
|
795
|
+
const page = paginate(items, args, self, item => item.peerKey);
|
|
796
|
+
const complete = result.items.length >= result.total;
|
|
797
|
+
return {
|
|
798
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION, self, targetType, items: page.items, nextCursor: page.nextCursor,
|
|
799
|
+
total: result.total,
|
|
800
|
+
dataSource: {
|
|
801
|
+
kind: 'aun-live', authoritative: true, complete, observedAt: Date.now(),
|
|
802
|
+
limitation: complete ? null : `AUN returned ${result.items.length} of ${result.total} joined groups without a continuation cursor`,
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
if (targetType !== 'private') {
|
|
807
|
+
throw validationFailed(self, undefined, [{ field: 'targetType', code: 'INVALID_VALUE', message: 'targetType must be private or group' }]);
|
|
808
|
+
}
|
|
809
|
+
const candidates = privateHistoryTargets(self);
|
|
810
|
+
const page = paginate(candidates, args, self, item => item.peerKey);
|
|
811
|
+
return {
|
|
812
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION, self, targetType, items: page.items, nextCursor: page.nextCursor,
|
|
813
|
+
dataSource: {
|
|
814
|
+
kind: 'local-history', authoritative: false, complete: false, observedAt: Date.now(),
|
|
815
|
+
limitation: 'History removed from local storage cannot be recovered',
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
function roleDirectory(context) {
|
|
820
|
+
return context.directory ?? DEFAULT_DIRECTORY;
|
|
821
|
+
}
|
|
822
|
+
function assertAunDirectory(context) {
|
|
823
|
+
if (context.aunAvailable === false) {
|
|
824
|
+
throw roleError('NOT_SUPPORTED', 'Role target validation requires an enabled AUN channel', context.self, {
|
|
825
|
+
kind: 'aun_directory_unavailable',
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
async function listJoinedGroups(context) {
|
|
830
|
+
assertAunDirectory(context);
|
|
831
|
+
const directory = roleDirectory(context);
|
|
832
|
+
const items = new Map();
|
|
833
|
+
let cursor;
|
|
834
|
+
let total = 0;
|
|
835
|
+
let attempts = 0;
|
|
836
|
+
while (attempts < 1000) {
|
|
837
|
+
attempts += 1;
|
|
838
|
+
const result = await directory.listGroups({ from: context.self, size: MAX_PAGE_SIZE, ...(cursor ? { cursor } : {}) });
|
|
839
|
+
if (!result.ok) {
|
|
840
|
+
throw roleError('TEMPORARILY_UNAVAILABLE', result.error || 'Unable to list joined groups', context.self, {
|
|
841
|
+
kind: 'target_source_unavailable', targetType: 'group',
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
total = Math.max(total, result.total);
|
|
845
|
+
const previousSize = items.size;
|
|
846
|
+
for (const group of result.items)
|
|
847
|
+
items.set(group.group_id, group);
|
|
848
|
+
if (items.size >= total)
|
|
849
|
+
break;
|
|
850
|
+
if (items.size === previousSize)
|
|
851
|
+
break;
|
|
852
|
+
if (result.nextCursor && result.nextCursor !== cursor) {
|
|
853
|
+
cursor = result.nextCursor;
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
return { items: [...items.values()], total };
|
|
859
|
+
}
|
|
860
|
+
async function knownRelationTargets(context) {
|
|
861
|
+
const known = localKnownRelationTargets(context.self);
|
|
862
|
+
if (context.aunAvailable === false)
|
|
863
|
+
return known;
|
|
864
|
+
try {
|
|
865
|
+
const groups = await listJoinedGroups(context);
|
|
866
|
+
for (const group of groups.items)
|
|
867
|
+
known.groups.add(group.group_id);
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
return known;
|
|
871
|
+
}
|
|
872
|
+
return known;
|
|
873
|
+
}
|
|
874
|
+
function localKnownRelationTargets(self) {
|
|
875
|
+
const known = { groups: new Set(), privates: new Set() };
|
|
876
|
+
for (const chat of scanChatDirs(resolvePaths().sessionsDir)) {
|
|
877
|
+
if (chat.channelType !== 'aun' || chat.selfAID !== self)
|
|
878
|
+
continue;
|
|
879
|
+
const active = readJsonFile(path.join(chat.dirPath, 'active.json'));
|
|
880
|
+
if (chatIsGroup(chat))
|
|
881
|
+
known.groups.add(String(active?.metadata?.groupId || chat.channelId));
|
|
882
|
+
else
|
|
883
|
+
known.privates.add(String(active?.metadata?.peerId || chat.channelId));
|
|
884
|
+
}
|
|
885
|
+
return known;
|
|
886
|
+
}
|
|
887
|
+
async function validateTargetExists(context, target, options = {}) {
|
|
888
|
+
assertAunDirectory(context);
|
|
889
|
+
if (options.allowAuthenticatedPrivateActor === true
|
|
890
|
+
&& target.kind === 'private'
|
|
891
|
+
&& context.chatType === 'private'
|
|
892
|
+
&& context.actorAid === target.peerAid) {
|
|
893
|
+
return target;
|
|
894
|
+
}
|
|
895
|
+
const directory = roleDirectory(context);
|
|
896
|
+
if (target.kind === 'private') {
|
|
897
|
+
const lookup = await directory.lookupAid(target.peerAid);
|
|
898
|
+
if (!lookup.exists) {
|
|
899
|
+
throw roleError('NOT_FOUND', 'Private Role target was not found', context.self, {
|
|
900
|
+
kind: 'target_not_found', target,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
const declaredType = lookup.content?.match(/^type:\s*["']?([^"'\r\n]+)["']?\s*$/mi)?.[1]?.trim().toLowerCase();
|
|
904
|
+
const groupProbe = await directory.getGroup({ from: context.self, groupId: target.peerAid, required: ['member'] });
|
|
905
|
+
if (declaredType === 'group' || (groupProbe.ok && groupProbe.found)) {
|
|
906
|
+
throw roleError('INVALID_ARGUMENT', 'Private Role target resolves to a group', context.self, {
|
|
907
|
+
kind: 'target_kind_mismatch', target,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
return target;
|
|
911
|
+
}
|
|
912
|
+
const info = await directory.getGroup({ from: context.self, groupId: target.groupId, required: ['member'] });
|
|
913
|
+
if (!info.ok) {
|
|
914
|
+
throw roleError('NOT_FOUND', info.error || 'Group Role target was not found', context.self, {
|
|
915
|
+
kind: 'target_not_found', target,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
const group = info.group;
|
|
919
|
+
if (!info.found || !group || !group.my_role || group.status === 'dissolved') {
|
|
920
|
+
throw roleError('NOT_FOUND', 'Group Role target is not joined by this Agent', context.self, {
|
|
921
|
+
kind: 'target_not_joined', target,
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
const canonicalGroupId = group.group_aid || group.group_id || target.groupId;
|
|
925
|
+
return canonicalGroupId === target.groupId
|
|
926
|
+
? target
|
|
927
|
+
: { ...target, groupId: canonicalGroupId, peerKey: formatPeerKey('aun', canonicalGroupId) };
|
|
928
|
+
}
|
|
929
|
+
function privateHistoryTargets(self) {
|
|
930
|
+
const aggregate = new Map();
|
|
931
|
+
for (const chat of scanChatDirs(resolvePaths().sessionsDir)) {
|
|
932
|
+
if (chat.channelType !== 'aun' || chat.selfAID !== self || chatIsGroup(chat))
|
|
933
|
+
continue;
|
|
934
|
+
const sessions = [];
|
|
935
|
+
const active = readJsonFile(path.join(chat.dirPath, 'active.json'));
|
|
936
|
+
if (active)
|
|
937
|
+
sessions.push(active);
|
|
938
|
+
for (const metaFile of scanMetaFiles(chat.dirPath)) {
|
|
939
|
+
const session = readLastJsonlLine(path.join(chat.dirPath, metaFile));
|
|
940
|
+
if (session)
|
|
941
|
+
sessions.push(session);
|
|
942
|
+
}
|
|
943
|
+
if (!sessions.length)
|
|
944
|
+
continue;
|
|
945
|
+
const times = sessions.map(session => Number(session.updatedAt || session.createdAt || 0)).filter(Number.isFinite);
|
|
946
|
+
const created = sessions.map(session => Number(session.createdAt || 0)).filter(Number.isFinite);
|
|
947
|
+
aggregate.set(chat.channelId, {
|
|
948
|
+
first: created.length ? Math.min(...created) : 0,
|
|
949
|
+
last: times.length ? Math.max(...times) : 0,
|
|
950
|
+
count: new Set(sessions.map(session => session.id).filter(Boolean)).size,
|
|
951
|
+
displayName: sessions.find(session => session.metadata?.peerName)?.metadata?.peerName,
|
|
952
|
+
peerType: sessions.find(session => session.metadata?.peerType)?.metadata?.peerType,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
return [...aggregate.entries()].map(([peerAid, history]) => {
|
|
956
|
+
const peerKey = formatPeerKey('aun', peerAid);
|
|
957
|
+
const relation = readRelation(peerKey, self);
|
|
958
|
+
const view = targetRoleView(self, { kind: 'private', peerAid, peerKey }, relation?.config ?? null);
|
|
959
|
+
return {
|
|
960
|
+
kind: 'private', peerAid, peerKey, displayName: history.displayName ?? peerAid,
|
|
961
|
+
peerType: history.peerType ?? 'unknown', lastSessionAt: history.last, firstSessionAt: history.first,
|
|
962
|
+
sessionCount: history.count, relationRevision: relation?.revision ?? null,
|
|
963
|
+
explicitRole: view.assignment.explicitRole,
|
|
964
|
+
effectiveRole: view.effectiveRole.roleId,
|
|
965
|
+
effectiveRoleSource: view.effectiveRole.source,
|
|
966
|
+
};
|
|
967
|
+
}).sort((a, b) => a.peerKey.localeCompare(b.peerKey));
|
|
968
|
+
}
|
|
969
|
+
function chatIsGroup(chat) {
|
|
970
|
+
if (isExplicitGroupId(chat.channelId))
|
|
971
|
+
return true;
|
|
972
|
+
const active = readJsonFile(path.join(chat.dirPath, 'active.json'));
|
|
973
|
+
if (active?.chatType === 'group' || active?.metadata?.groupId)
|
|
974
|
+
return true;
|
|
975
|
+
return scanMetaFiles(chat.dirPath).some(metaFile => {
|
|
976
|
+
const session = readLastJsonlLine(path.join(chat.dirPath, metaFile));
|
|
977
|
+
return session?.chatType === 'group' || !!session?.metadata?.groupId;
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
function assignmentResponse(self, target, config, changed, relationRevision) {
|
|
981
|
+
return {
|
|
982
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION,
|
|
983
|
+
self,
|
|
984
|
+
resource: 'assignment',
|
|
985
|
+
target,
|
|
986
|
+
changed,
|
|
987
|
+
relationRevision,
|
|
988
|
+
...targetRoleView(self, target, config),
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
function normalizeTarget(value, self) {
|
|
992
|
+
if (!isObject(value))
|
|
993
|
+
throw validationFailed(self, undefined, [{ field: 'target', code: 'INVALID_TYPE', message: 'target must be an object' }]);
|
|
994
|
+
const kind = stringArg(value.kind);
|
|
995
|
+
if (kind === 'private') {
|
|
996
|
+
const peerAid = stringArg(value.peerAid);
|
|
997
|
+
if (!peerAid)
|
|
998
|
+
throw invalidTarget(self, 'target.peerAid');
|
|
999
|
+
if (!isValidAid(peerAid) || isExplicitGroupId(peerAid))
|
|
1000
|
+
throw invalidTargetValue(self, 'target.peerAid');
|
|
1001
|
+
return { kind, peerAid, peerKey: formatPeerKey('aun', peerAid) };
|
|
1002
|
+
}
|
|
1003
|
+
if (kind === 'group') {
|
|
1004
|
+
const groupId = stringArg(value.groupId);
|
|
1005
|
+
if (!groupId)
|
|
1006
|
+
throw invalidTarget(self, 'target.groupId');
|
|
1007
|
+
if (!isValidAid(groupId) && !isExplicitGroupId(groupId))
|
|
1008
|
+
throw invalidTargetValue(self, 'target.groupId');
|
|
1009
|
+
return { kind, groupId, peerKey: formatPeerKey('aun', groupId) };
|
|
1010
|
+
}
|
|
1011
|
+
throw validationFailed(self, undefined, [{ field: 'target.kind', code: 'INVALID_VALUE', message: 'target.kind must be private or group' }]);
|
|
1012
|
+
}
|
|
1013
|
+
function targetRoleView(self, target, relation = null) {
|
|
1014
|
+
if (target.kind === 'private') {
|
|
1015
|
+
const explicitRole = relation?.role ?? null;
|
|
1016
|
+
const detail = resolvePeerRoleDetail({
|
|
1017
|
+
selfAid: self, channelKey: `aun#${self}#main`, channelType: 'aun', chatType: 'private', actorId: target.peerAid, conversationId: target.peerAid,
|
|
1018
|
+
});
|
|
1019
|
+
return {
|
|
1020
|
+
assignment: { explicitRole },
|
|
1021
|
+
effectiveRole: roleResult(detail.effectiveRole, detail.source, self),
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
const explicitDefaultRole = relation?.role ?? null;
|
|
1025
|
+
const roleId = validRoleOrNull(explicitDefaultRole, self) ?? effectiveDefaultRoles(self).group;
|
|
1026
|
+
return {
|
|
1027
|
+
assignment: { explicitDefaultRole },
|
|
1028
|
+
baselineRole: roleResult(roleId, explicitDefaultRole ? 'relation' : 'agent-default', self),
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
function relationConfigView(context, target, config) {
|
|
1032
|
+
const self = context.self;
|
|
1033
|
+
const permissionFields = permissionFieldsForContext(context);
|
|
1034
|
+
const explicit = {};
|
|
1035
|
+
for (const field of permissionFields) {
|
|
1036
|
+
const value = getNested(config, field.value);
|
|
1037
|
+
if (value !== undefined)
|
|
1038
|
+
explicit[field.value] = value;
|
|
1039
|
+
}
|
|
1040
|
+
const roleView = targetRoleView(self, target, config);
|
|
1041
|
+
const roleId = roleView.effectiveRole?.roleId ?? roleView.baselineRole?.roleId ?? null;
|
|
1042
|
+
const roleDefinition = roleId ? getRoleDefinition(roleId, self) : null;
|
|
1043
|
+
const effective = resolveEffectiveWithSources({ self, peerKey: target.peerKey }, { cache: false, fields: permissionFields.filter(field => field.value !== 'permissionMode').map(field => field.value) });
|
|
1044
|
+
return {
|
|
1045
|
+
explicit,
|
|
1046
|
+
fields: permissionFields.map(field => {
|
|
1047
|
+
const explicitValue = getNested(config, field.value);
|
|
1048
|
+
const resolved = effective[field.value];
|
|
1049
|
+
const isPermissionMode = field.value === 'permissionMode';
|
|
1050
|
+
const operation = relationFieldWriteOperation(field.value);
|
|
1051
|
+
const fieldPermission = roleDefinition
|
|
1052
|
+
? resolveRoleFieldPermission(roleDefinition.permissions || {}, field.value)
|
|
1053
|
+
: undefined;
|
|
1054
|
+
const operationCapability = roleId && operation
|
|
1055
|
+
? evaluateRoleOperationCapability({
|
|
1056
|
+
role: roleId,
|
|
1057
|
+
selfAid: self,
|
|
1058
|
+
operation,
|
|
1059
|
+
scope: 'relation',
|
|
1060
|
+
chatType: target.kind === 'group' ? 'group' : 'private',
|
|
1061
|
+
})
|
|
1062
|
+
: null;
|
|
1063
|
+
const appliesToTarget = field.value === 'chatmode.private'
|
|
1064
|
+
? target.kind === 'private'
|
|
1065
|
+
: field.value === 'chatmode.group' || field.value === 'mentionMode'
|
|
1066
|
+
? target.kind === 'group'
|
|
1067
|
+
: true;
|
|
1068
|
+
const writable = appliesToTarget
|
|
1069
|
+
&& !isPermissionMode
|
|
1070
|
+
&& !!operationCapability?.allow
|
|
1071
|
+
&& fieldPermission?.allowOverride !== false;
|
|
1072
|
+
const writeReason = writable
|
|
1073
|
+
? undefined
|
|
1074
|
+
: !appliesToTarget
|
|
1075
|
+
? `${field.value} does not apply to this relation type`
|
|
1076
|
+
: isPermissionMode
|
|
1077
|
+
? 'permissionMode is managed by the role policy'
|
|
1078
|
+
: !roleId
|
|
1079
|
+
? 'No effective role is assigned'
|
|
1080
|
+
: fieldPermission?.allowOverride === false
|
|
1081
|
+
? `Role ${roleId} does not allow relation overrides for ${field.value}`
|
|
1082
|
+
: operationCapability?.reason ?? `Role ${roleId} cannot write ${field.value}`;
|
|
1083
|
+
const modelMatch = /^baseagents\.([^.]+)\.model$/.exec(field.value);
|
|
1084
|
+
const permissionDecision = isPermissionMode
|
|
1085
|
+
? resolveRuntimePermissionMode({ selfAid: self, role: roleId })
|
|
1086
|
+
: null;
|
|
1087
|
+
const modelDecision = modelMatch
|
|
1088
|
+
? constrainResolvedModelForRole({
|
|
1089
|
+
selfAid: self,
|
|
1090
|
+
role: roleId ?? 'none',
|
|
1091
|
+
baseagent: modelMatch[1],
|
|
1092
|
+
model: typeof resolved?.value === 'string' ? resolved.value : undefined,
|
|
1093
|
+
resolveModelId: context.resolveModelId
|
|
1094
|
+
? model => context.resolveModelId(modelMatch[1], model)
|
|
1095
|
+
: undefined,
|
|
1096
|
+
})
|
|
1097
|
+
: null;
|
|
1098
|
+
const stringDecision = !isPermissionMode && !modelMatch
|
|
1099
|
+
? resolveRuntimeStringField({
|
|
1100
|
+
selfAid: self,
|
|
1101
|
+
role: roleId,
|
|
1102
|
+
field: field.value,
|
|
1103
|
+
configuredValue: resolved?.value,
|
|
1104
|
+
})
|
|
1105
|
+
: null;
|
|
1106
|
+
return {
|
|
1107
|
+
field: field.value,
|
|
1108
|
+
hasExplicitValue: explicitValue !== undefined,
|
|
1109
|
+
...(explicitValue !== undefined ? { explicitValue } : {}),
|
|
1110
|
+
effectiveValue: permissionDecision?.effectiveValue
|
|
1111
|
+
?? modelDecision?.model
|
|
1112
|
+
?? stringDecision?.effectiveValue
|
|
1113
|
+
?? resolved?.value
|
|
1114
|
+
?? null,
|
|
1115
|
+
effectiveContext: target.kind === 'private' ? 'private-peer' : 'group-baseline',
|
|
1116
|
+
source: permissionDecision
|
|
1117
|
+
? (permissionDecision.decidedBy === 'role' ? 'role' : 'builtin')
|
|
1118
|
+
: modelDecision?.constrained
|
|
1119
|
+
? 'role'
|
|
1120
|
+
: stringDecision?.decidedBy === 'role'
|
|
1121
|
+
? 'role'
|
|
1122
|
+
: resolved?.source ?? null,
|
|
1123
|
+
...(modelDecision ? {
|
|
1124
|
+
configuredValue: resolved?.value ?? null,
|
|
1125
|
+
configuredSource: resolved?.source ?? null,
|
|
1126
|
+
constrainedByRole: modelDecision.constrained,
|
|
1127
|
+
} : {}),
|
|
1128
|
+
...(stringDecision ? {
|
|
1129
|
+
configuredValue: resolved?.value ?? null,
|
|
1130
|
+
configuredSource: resolved?.source ?? null,
|
|
1131
|
+
constrainedByRole: stringDecision.constrained,
|
|
1132
|
+
} : {}),
|
|
1133
|
+
readable: true,
|
|
1134
|
+
...(operation ? { operation } : {}),
|
|
1135
|
+
writable,
|
|
1136
|
+
...(writeReason ? { writeReason } : {}),
|
|
1137
|
+
};
|
|
1138
|
+
}),
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
function roleResult(roleId, source, self) {
|
|
1142
|
+
return { roleId, source: roleId ? source : 'none', allowAccess: roleId ? checkRoleAccess(roleId, self) : false };
|
|
1143
|
+
}
|
|
1144
|
+
function readAgent(self) {
|
|
1145
|
+
const agent = read(ConfigTarget.Agent, { self }, { cache: false });
|
|
1146
|
+
if (!agent)
|
|
1147
|
+
throw roleError('NOT_FOUND', 'Agent was not found', self, { kind: 'agent_not_found' });
|
|
1148
|
+
return agent;
|
|
1149
|
+
}
|
|
1150
|
+
function effectiveDefaultRoles(self) {
|
|
1151
|
+
const defaults = readRolesConfig(self).defaultRoles ?? {};
|
|
1152
|
+
return { private: defaults.private ?? null, group: defaults.group ?? null };
|
|
1153
|
+
}
|
|
1154
|
+
function policyDocument(agent) {
|
|
1155
|
+
const store = readRoleStore(agent.aid);
|
|
1156
|
+
return {
|
|
1157
|
+
$schema_version: ROLE_MENU_SCHEMA_VERSION,
|
|
1158
|
+
registry: store.registry,
|
|
1159
|
+
definitions: store.definitions,
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
function roleMeta(self, roleId) {
|
|
1163
|
+
if (isManagementRole(roleId)) {
|
|
1164
|
+
const overridden = fs.existsSync(agentRoleConfig(self, roleId));
|
|
1165
|
+
return {
|
|
1166
|
+
origin: 'builtin',
|
|
1167
|
+
overridden,
|
|
1168
|
+
kind: 'management',
|
|
1169
|
+
assignable: roleId === 'admin',
|
|
1170
|
+
editable: true,
|
|
1171
|
+
deletable: false,
|
|
1172
|
+
resettable: true,
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
const builtin = Object.prototype.hasOwnProperty.call(getBuiltinRolesConfig().roles, roleId);
|
|
1176
|
+
const overridden = fs.existsSync(agentRoleConfig(self, roleId));
|
|
1177
|
+
return {
|
|
1178
|
+
origin: builtin ? 'builtin' : 'custom',
|
|
1179
|
+
overridden,
|
|
1180
|
+
kind: 'user',
|
|
1181
|
+
assignable: true,
|
|
1182
|
+
editable: true,
|
|
1183
|
+
deletable: !builtin,
|
|
1184
|
+
resettable: builtin,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
function assertPolicyRevision(self, args, providedAgent) {
|
|
1188
|
+
if (!Object.prototype.hasOwnProperty.call(args ?? {}, 'expectedPolicyRevision')) {
|
|
1189
|
+
throw validationFailed(self, undefined, [{ field: 'expectedPolicyRevision', code: 'MISSING_FIELD', message: 'expectedPolicyRevision is required' }]);
|
|
1190
|
+
}
|
|
1191
|
+
const current = revision(policyDocument(providedAgent ?? readAgent(self)));
|
|
1192
|
+
if (args?.expectedPolicyRevision !== current) {
|
|
1193
|
+
throw revisionConflict(self, 'policy', args?.expectedPolicyRevision, current);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
function assertRelationRevision(self, args, current, peerKey) {
|
|
1197
|
+
if (!Object.prototype.hasOwnProperty.call(args ?? {}, 'expectedRelationRevision')) {
|
|
1198
|
+
throw validationFailed(self, undefined, [{ field: 'expectedRelationRevision', code: 'MISSING_FIELD', message: 'expectedRelationRevision is required' }]);
|
|
1199
|
+
}
|
|
1200
|
+
if (args?.expectedRelationRevision !== current) {
|
|
1201
|
+
throw revisionConflict(self, 'relation', args?.expectedRelationRevision, current, { peerKey });
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
function parseUpdateValue(rawValue, self) {
|
|
1205
|
+
if (Buffer.byteLength(rawValue, 'utf8') > ROLE_UPDATE_MAX_BYTES) {
|
|
1206
|
+
throw roleError('INVALID_ARGUMENT', 'Role update value is too large', self, { kind: 'invalid_json_value', maxBytes: ROLE_UPDATE_MAX_BYTES });
|
|
1207
|
+
}
|
|
1208
|
+
try {
|
|
1209
|
+
return JSON.parse(rawValue);
|
|
1210
|
+
}
|
|
1211
|
+
catch {
|
|
1212
|
+
throw roleError('INVALID_ARGUMENT', 'Role update value must contain valid JSON text', self, { kind: 'invalid_json_value' });
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
function validateAssignableRole(value, self, field) {
|
|
1216
|
+
if (value === null)
|
|
1217
|
+
return null;
|
|
1218
|
+
if (typeof value !== 'string' || !value.trim() || value === 'owner' || !getRoleDefinition(value, self)) {
|
|
1219
|
+
throw validationFailed(self, undefined, [{ field, code: 'INVALID_VALUE', message: 'value must be null or an existing assignable user role' }]);
|
|
1220
|
+
}
|
|
1221
|
+
return value;
|
|
1222
|
+
}
|
|
1223
|
+
function mergeDefinition(current, patch, remove, self, roleId) {
|
|
1224
|
+
assertOnlyKeys(patch, ['description', 'allowAccess', 'permissions', 'commandPermissions', 'usageLimits'], 'definition', self);
|
|
1225
|
+
const next = clone(current);
|
|
1226
|
+
if (patch.description !== undefined)
|
|
1227
|
+
next.description = patch.description;
|
|
1228
|
+
if (patch.allowAccess !== undefined)
|
|
1229
|
+
next.allowAccess = patch.allowAccess;
|
|
1230
|
+
if (patch.permissions !== undefined) {
|
|
1231
|
+
if (!isObject(patch.permissions))
|
|
1232
|
+
throw validationFailed(self, roleId, [{ field: 'permissions', code: 'INVALID_TYPE', message: 'permissions must be an object' }]);
|
|
1233
|
+
next.permissions = { ...(next.permissions ?? {}) };
|
|
1234
|
+
for (const [field, permission] of Object.entries(patch.permissions))
|
|
1235
|
+
next.permissions[field] = clone(permission);
|
|
1236
|
+
}
|
|
1237
|
+
if (patch.commandPermissions !== undefined) {
|
|
1238
|
+
if (!isObject(patch.commandPermissions))
|
|
1239
|
+
throw validationFailed(self, roleId, [{ field: 'commandPermissions', code: 'INVALID_TYPE', message: 'commandPermissions must be an object' }]);
|
|
1240
|
+
next.commandPermissions = { ...(next.commandPermissions ?? {}) };
|
|
1241
|
+
for (const [operation, permission] of Object.entries(patch.commandPermissions))
|
|
1242
|
+
next.commandPermissions[operation] = clone(permission);
|
|
1243
|
+
}
|
|
1244
|
+
if (patch.usageLimits !== undefined)
|
|
1245
|
+
next.usageLimits = clone(patch.usageLimits);
|
|
1246
|
+
const normalizedRemove = normalizeRemove(remove);
|
|
1247
|
+
for (const field of normalizedRemove.permissions)
|
|
1248
|
+
delete next.permissions[field];
|
|
1249
|
+
for (const operation of normalizedRemove.commandPermissions)
|
|
1250
|
+
delete next.commandPermissions?.[operation];
|
|
1251
|
+
if (normalizedRemove.usageLimits)
|
|
1252
|
+
delete next.usageLimits;
|
|
1253
|
+
return next;
|
|
1254
|
+
}
|
|
1255
|
+
function normalizeRemove(value) {
|
|
1256
|
+
if (!isObject(value))
|
|
1257
|
+
return { permissions: [], commandPermissions: [], usageLimits: false };
|
|
1258
|
+
return {
|
|
1259
|
+
permissions: Array.isArray(value.permissions) ? value.permissions.filter(item => typeof item === 'string') : [],
|
|
1260
|
+
commandPermissions: Array.isArray(value.commandPermissions) ? value.commandPermissions.filter(item => typeof item === 'string') : [],
|
|
1261
|
+
usageLimits: value.usageLimits === true,
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
function validateRemove(value, self, roleId) {
|
|
1265
|
+
if (value === undefined)
|
|
1266
|
+
return;
|
|
1267
|
+
const errors = [];
|
|
1268
|
+
if (!isObject(value)) {
|
|
1269
|
+
throw validationFailed(self, roleId, [{ field: 'remove', code: 'INVALID_TYPE', message: 'remove must be an object' }]);
|
|
1270
|
+
}
|
|
1271
|
+
for (const key of Object.keys(value)) {
|
|
1272
|
+
if (!['permissions', 'commandPermissions', 'usageLimits'].includes(key))
|
|
1273
|
+
errors.push({ field: `remove.${key}`, code: 'UNKNOWN_FIELD', message: `Unknown remove field: ${key}` });
|
|
1274
|
+
}
|
|
1275
|
+
for (const field of ['permissions', 'commandPermissions']) {
|
|
1276
|
+
const item = value[field];
|
|
1277
|
+
if (item !== undefined && (!Array.isArray(item) || !item.every(entry => typeof entry === 'string' && entry.length > 0))) {
|
|
1278
|
+
errors.push({ field: `remove.${field}`, code: 'INVALID_TYPE', message: `${field} must be a non-empty string array` });
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
if (value.usageLimits !== undefined && typeof value.usageLimits !== 'boolean')
|
|
1282
|
+
errors.push({ field: 'remove.usageLimits', code: 'INVALID_TYPE', message: 'usageLimits must be a boolean' });
|
|
1283
|
+
if (errors.length)
|
|
1284
|
+
throw validationFailed(self, roleId, errors);
|
|
1285
|
+
}
|
|
1286
|
+
function listRelations(self, known = { groups: new Set(), privates: new Set() }) {
|
|
1287
|
+
const dir = agentRelationsDir(self);
|
|
1288
|
+
if (!fs.existsSync(dir))
|
|
1289
|
+
return [];
|
|
1290
|
+
const records = [];
|
|
1291
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1292
|
+
if (!entry.isDirectory() || entry.name.startsWith('_'))
|
|
1293
|
+
continue;
|
|
1294
|
+
let parsed;
|
|
1295
|
+
try {
|
|
1296
|
+
parsed = parsePeerKey(entry.name);
|
|
1297
|
+
}
|
|
1298
|
+
catch {
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
const config = read(ConfigTarget.Relation, { self, peerKey: entry.name }, { cache: false });
|
|
1302
|
+
if (!config)
|
|
1303
|
+
continue;
|
|
1304
|
+
const targetKind = relationTargetKind(config, parsed.channelId, known);
|
|
1305
|
+
records.push({
|
|
1306
|
+
peerKey: entry.name,
|
|
1307
|
+
config,
|
|
1308
|
+
revision: revision(config),
|
|
1309
|
+
...parsed,
|
|
1310
|
+
targetKind,
|
|
1311
|
+
isGroup: targetKind === 'group',
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
return records;
|
|
1315
|
+
}
|
|
1316
|
+
function readRelation(peerKey, self) {
|
|
1317
|
+
const config = read(ConfigTarget.Relation, { self, peerKey }, { cache: false });
|
|
1318
|
+
if (!config)
|
|
1319
|
+
return null;
|
|
1320
|
+
const parsed = parsePeerKey(peerKey);
|
|
1321
|
+
const targetKind = relationTargetKind(config, parsed.channelId);
|
|
1322
|
+
return { peerKey, config, revision: revision(config), ...parsed, targetKind, isGroup: targetKind === 'group' };
|
|
1323
|
+
}
|
|
1324
|
+
function relationTargetKind(config, channelId, known = { groups: new Set(), privates: new Set() }) {
|
|
1325
|
+
if (config.targetKind)
|
|
1326
|
+
return config.targetKind;
|
|
1327
|
+
if (known.groups.has(channelId) || isExplicitGroupId(channelId))
|
|
1328
|
+
return 'group';
|
|
1329
|
+
if (known.privates.has(channelId))
|
|
1330
|
+
return 'private';
|
|
1331
|
+
return 'unknown';
|
|
1332
|
+
}
|
|
1333
|
+
function countRoleUsage(self, relations, roleId) {
|
|
1334
|
+
const configuredDefaults = effectiveDefaultRoles(self);
|
|
1335
|
+
const defaultScenes = ['private', 'group'].filter(scene => configuredDefaults[scene] === roleId);
|
|
1336
|
+
let privateRelations = 0;
|
|
1337
|
+
let groupRelations = 0;
|
|
1338
|
+
for (const relation of relations) {
|
|
1339
|
+
if (relation.config.role === roleId) {
|
|
1340
|
+
if (relation.isGroup)
|
|
1341
|
+
groupRelations += 1;
|
|
1342
|
+
else
|
|
1343
|
+
privateRelations += 1;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
return { defaultScenes, privateRelations, groupRelations };
|
|
1347
|
+
}
|
|
1348
|
+
function relationReferencesRole(config, roleId) {
|
|
1349
|
+
return config.role === roleId;
|
|
1350
|
+
}
|
|
1351
|
+
function appendRoleRelationChanges(self, relations, roleId, replacement, changes) {
|
|
1352
|
+
for (const relation of relations) {
|
|
1353
|
+
if (!relationReferencesRole(relation.config, roleId))
|
|
1354
|
+
continue;
|
|
1355
|
+
const next = clone(relation.config);
|
|
1356
|
+
if (replacement === null)
|
|
1357
|
+
delete next.role;
|
|
1358
|
+
else
|
|
1359
|
+
next.role = replacement;
|
|
1360
|
+
const normalized = normalizeRelationForScene(next, relation.isGroup ? 'group' : 'private');
|
|
1361
|
+
const file = agentRelationConfig(self, relation.peerKey);
|
|
1362
|
+
changes.push({ file, before: readFileText(file), after: jsonText(normalized) });
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
function validateRoleDomainTransactionChanges(self, registry, changes) {
|
|
1366
|
+
const schema = loadSchema('role-registry');
|
|
1367
|
+
if (!schema.validate(registry)) {
|
|
1368
|
+
throw validationFailed(self, undefined, (schema.validate.errors ?? []).map(error => ({
|
|
1369
|
+
field: 'registry', code: 'SCHEMA_INVALID', message: error.message ?? 'invalid role registry',
|
|
1370
|
+
})));
|
|
1371
|
+
}
|
|
1372
|
+
for (const change of changes) {
|
|
1373
|
+
if (!change.after || !change.file.endsWith('.json') || change.file.endsWith('index.json'))
|
|
1374
|
+
continue;
|
|
1375
|
+
const isRelation = change.file.includes(`${path.sep}relations${path.sep}`);
|
|
1376
|
+
const schema = loadSchema(isRelation ? 'relation-config' : 'role-config');
|
|
1377
|
+
const value = JSON.parse(change.after);
|
|
1378
|
+
if (!schema.validate(value)) {
|
|
1379
|
+
throw validationFailed(self, undefined, [{
|
|
1380
|
+
field: isRelation ? 'relation' : 'role',
|
|
1381
|
+
code: 'SCHEMA_INVALID',
|
|
1382
|
+
message: isRelation ? 'invalid relation config' : 'invalid role definition',
|
|
1383
|
+
}]);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
function normalizeRelationForScene(value, scene) {
|
|
1388
|
+
const normalized = clone(value);
|
|
1389
|
+
normalized.$schema_version = currentVersion('relation-config');
|
|
1390
|
+
normalized.targetKind = scene;
|
|
1391
|
+
return normalized;
|
|
1392
|
+
}
|
|
1393
|
+
function commitJournal(self, changes) {
|
|
1394
|
+
const journalFile = roleJournalPath(self);
|
|
1395
|
+
const journal = { $schema_version: 1, self, state: 'prepared', changes };
|
|
1396
|
+
atomicWriteJson(journalFile, journal);
|
|
1397
|
+
try {
|
|
1398
|
+
for (const change of changes)
|
|
1399
|
+
applyFileContent(change.file, change.after);
|
|
1400
|
+
journal.state = 'committed';
|
|
1401
|
+
atomicWriteJson(journalFile, journal);
|
|
1402
|
+
removeJournalFiles(journalFile);
|
|
1403
|
+
invalidateChanges(changes);
|
|
1404
|
+
}
|
|
1405
|
+
catch (error) {
|
|
1406
|
+
try {
|
|
1407
|
+
recoverRoleMutationSync(self);
|
|
1408
|
+
}
|
|
1409
|
+
catch { }
|
|
1410
|
+
throw error;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
function removeJournalFiles(journalFile) {
|
|
1414
|
+
// Remove backups first. If the process exits mid-cleanup, a committed main
|
|
1415
|
+
// journal remains authoritative and recovery will keep the committed files.
|
|
1416
|
+
for (const candidate of [`${journalFile}_`, `${journalFile}__`, journalFile]) {
|
|
1417
|
+
try {
|
|
1418
|
+
fs.unlinkSync(candidate);
|
|
1419
|
+
}
|
|
1420
|
+
catch (error) {
|
|
1421
|
+
if (error?.code !== 'ENOENT')
|
|
1422
|
+
throw error;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
function validateJournal(self, journal) {
|
|
1427
|
+
const root = path.resolve(agentDir(self));
|
|
1428
|
+
if (journal.$schema_version !== 1 || journal.self !== self
|
|
1429
|
+
|| (journal.state !== 'prepared' && journal.state !== 'committed')
|
|
1430
|
+
|| !Array.isArray(journal.changes))
|
|
1431
|
+
throw new Error('Invalid Role mutation journal');
|
|
1432
|
+
const seen = new Set();
|
|
1433
|
+
for (const change of journal.changes) {
|
|
1434
|
+
if (!change || typeof change !== 'object' || typeof change.file !== 'string'
|
|
1435
|
+
|| (change.before !== null && typeof change.before !== 'string')
|
|
1436
|
+
|| (change.after !== null && typeof change.after !== 'string')) {
|
|
1437
|
+
throw new Error('Role mutation journal contains an invalid change');
|
|
1438
|
+
}
|
|
1439
|
+
const file = path.resolve(change.file);
|
|
1440
|
+
if (file === root || !file.startsWith(root + path.sep))
|
|
1441
|
+
throw new Error('Role mutation journal contains an unsafe path');
|
|
1442
|
+
if (seen.has(file))
|
|
1443
|
+
throw new Error('Role mutation journal contains duplicate file changes');
|
|
1444
|
+
seen.add(file);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
function applyFileContent(file, content) {
|
|
1448
|
+
if (content === null) {
|
|
1449
|
+
for (const candidate of [file, `${file}_`, `${file}__`]) {
|
|
1450
|
+
try {
|
|
1451
|
+
fs.unlinkSync(candidate);
|
|
1452
|
+
}
|
|
1453
|
+
catch (error) {
|
|
1454
|
+
if (error?.code !== 'ENOENT')
|
|
1455
|
+
throw error;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
atomicWrite(file, content);
|
|
1461
|
+
}
|
|
1462
|
+
function invalidateChanges(changes) {
|
|
1463
|
+
for (const change of changes)
|
|
1464
|
+
fileCache.invalidate(change.file);
|
|
1465
|
+
clearRoleStoreCache();
|
|
1466
|
+
}
|
|
1467
|
+
function roleJournalPath(self) {
|
|
1468
|
+
return path.join(agentDir(self), ROLE_JOURNAL_FILE);
|
|
1469
|
+
}
|
|
1470
|
+
async function withAgentMutation(self, operation) {
|
|
1471
|
+
const previous = mutationTails.get(self) ?? Promise.resolve();
|
|
1472
|
+
let release;
|
|
1473
|
+
const current = new Promise(resolve => { release = resolve; });
|
|
1474
|
+
const queued = previous.then(() => current);
|
|
1475
|
+
mutationTails.set(self, queued);
|
|
1476
|
+
await previous;
|
|
1477
|
+
try {
|
|
1478
|
+
return await operation();
|
|
1479
|
+
}
|
|
1480
|
+
finally {
|
|
1481
|
+
release();
|
|
1482
|
+
if (mutationTails.get(self) === queued)
|
|
1483
|
+
mutationTails.delete(self);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
function paginate(items, args, self, key) {
|
|
1487
|
+
const limit = normalizeLimit(args?.limit);
|
|
1488
|
+
let start = 0;
|
|
1489
|
+
if (args?.cursor !== undefined && args.cursor !== null) {
|
|
1490
|
+
try {
|
|
1491
|
+
const decoded = JSON.parse(Buffer.from(String(args.cursor), 'base64url').toString('utf8'));
|
|
1492
|
+
if (!decoded || typeof decoded.after !== 'string')
|
|
1493
|
+
throw new Error();
|
|
1494
|
+
start = items.findIndex(item => key(item) > decoded.after);
|
|
1495
|
+
if (start < 0)
|
|
1496
|
+
start = items.length;
|
|
1497
|
+
}
|
|
1498
|
+
catch {
|
|
1499
|
+
throw validationFailed(self, undefined, [{ field: 'cursor', code: 'INVALID_VALUE', message: 'cursor is invalid' }]);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
const page = items.slice(start, start + limit);
|
|
1503
|
+
const hasMore = start + page.length < items.length;
|
|
1504
|
+
return {
|
|
1505
|
+
items: page,
|
|
1506
|
+
nextCursor: hasMore && page.length
|
|
1507
|
+
? Buffer.from(JSON.stringify({ after: key(page[page.length - 1]) }), 'utf8').toString('base64url')
|
|
1508
|
+
: null,
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
function normalizeLimit(value) {
|
|
1512
|
+
const number = Number(value ?? DEFAULT_PAGE_SIZE);
|
|
1513
|
+
if (!Number.isInteger(number) || number <= 0)
|
|
1514
|
+
return DEFAULT_PAGE_SIZE;
|
|
1515
|
+
return Math.min(number, MAX_PAGE_SIZE);
|
|
1516
|
+
}
|
|
1517
|
+
function assignmentKey(item) {
|
|
1518
|
+
const target = item.target;
|
|
1519
|
+
return [target.kind, target.scene ?? '', target.peerKey ?? '', target.memberAid ?? ''].join('\u0000');
|
|
1520
|
+
}
|
|
1521
|
+
function requiredRoleId(args, self) {
|
|
1522
|
+
const roleId = stringArg(args?.roleId);
|
|
1523
|
+
if (!roleId)
|
|
1524
|
+
throw validationFailed(self, undefined, [{ field: 'roleId', code: 'MISSING_FIELD', message: 'roleId is required' }]);
|
|
1525
|
+
return roleId;
|
|
1526
|
+
}
|
|
1527
|
+
function assertOnlyKeys(value, allowed, field, self) {
|
|
1528
|
+
const unexpected = Object.keys(value).filter(key => !allowed.includes(key));
|
|
1529
|
+
if (unexpected.length)
|
|
1530
|
+
throw validationFailed(self, undefined, unexpected.map(key => ({ field: `${field}.${key}`, code: 'UNKNOWN_FIELD', message: `Unknown field: ${key}` })));
|
|
1531
|
+
}
|
|
1532
|
+
function invalidTarget(self, field) {
|
|
1533
|
+
return validationFailed(self, undefined, [{ field, code: 'MISSING_FIELD', message: `${field} is required` }]);
|
|
1534
|
+
}
|
|
1535
|
+
function invalidTargetValue(self, field) {
|
|
1536
|
+
return validationFailed(self, undefined, [{ field, code: 'INVALID_VALUE', message: `${field} is not a valid AUN private target` }]);
|
|
1537
|
+
}
|
|
1538
|
+
function invalidJsonType(self, resource, expected) {
|
|
1539
|
+
return roleError('INVALID_ARGUMENT', `Role ${resource} value must decode to ${expected}`, self, {
|
|
1540
|
+
kind: 'invalid_json_value', resource, expected,
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
function roleNotFound(self, roleId) {
|
|
1544
|
+
return roleError('NOT_FOUND', 'Role was not found', self, { kind: 'role_not_found', roleId });
|
|
1545
|
+
}
|
|
1546
|
+
function protectedRole(self, roleId, allowedAction) {
|
|
1547
|
+
return roleError('NOT_ALLOWED', 'Built-in roles cannot be deleted; use reset instead', self, {
|
|
1548
|
+
kind: 'protected_role', roleId, allowedAction,
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
function revisionConflict(self, resource, expectedRevision, currentRevision, extra) {
|
|
1552
|
+
return roleError('CONFLICT', resource === 'policy' ? 'Role policy has changed' : 'Relation has changed', self, {
|
|
1553
|
+
kind: 'revision_conflict', resource, expectedRevision: expectedRevision ?? null, currentRevision, ...(extra ?? {}),
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
function validationFailed(self, roleId, errors) {
|
|
1557
|
+
return roleError('INVALID_ARGUMENT', 'RoleDefinition validation failed', self, {
|
|
1558
|
+
kind: 'validation_failed', ...(roleId ? { roleId } : {}), errors,
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
function roleError(code, message, self, data) {
|
|
1562
|
+
return new RoleMenuError(code, message, { $schema_version: ROLE_MENU_SCHEMA_VERSION, self, ...data });
|
|
1563
|
+
}
|
|
1564
|
+
function revision(value) {
|
|
1565
|
+
return `sha256:${crypto.createHash('sha256').update(stableStringify(value)).digest('hex')}`;
|
|
1566
|
+
}
|
|
1567
|
+
function stableStringify(value) {
|
|
1568
|
+
return JSON.stringify(sortValue(value));
|
|
1569
|
+
}
|
|
1570
|
+
function sortValue(value) {
|
|
1571
|
+
if (Array.isArray(value))
|
|
1572
|
+
return value.map(sortValue);
|
|
1573
|
+
if (!isObject(value))
|
|
1574
|
+
return value;
|
|
1575
|
+
return Object.fromEntries(Object.keys(value).sort().map(key => [key, sortValue(value[key])]));
|
|
1576
|
+
}
|
|
1577
|
+
function clone(value) {
|
|
1578
|
+
return value === undefined ? value : JSON.parse(JSON.stringify(value));
|
|
1579
|
+
}
|
|
1580
|
+
function stringArg(value) {
|
|
1581
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
1582
|
+
}
|
|
1583
|
+
function isObject(value) {
|
|
1584
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
1585
|
+
}
|
|
1586
|
+
function title(value) {
|
|
1587
|
+
return value.split(/[-_.]/g).filter(Boolean).map(part => part === 'xhigh' ? 'Extra High' : part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
|
|
1588
|
+
}
|
|
1589
|
+
function operationLabel(id) {
|
|
1590
|
+
const labels = {
|
|
1591
|
+
'role.policy.read': 'Read role policy',
|
|
1592
|
+
'role.policy.write': 'Write role policy',
|
|
1593
|
+
'role.relation.read': 'Read role assignments',
|
|
1594
|
+
'role.assign': 'Assign role',
|
|
1595
|
+
'role.revoke': 'Revoke role',
|
|
1596
|
+
};
|
|
1597
|
+
return labels[id] ?? title(id);
|
|
1598
|
+
}
|
|
1599
|
+
function validRoleOrNull(value, self) {
|
|
1600
|
+
return typeof value === 'string' && value !== 'owner' && getRoleDefinition(value, self) ? value : null;
|
|
1601
|
+
}
|
|
1602
|
+
function getNested(value, field) {
|
|
1603
|
+
let cursor = value;
|
|
1604
|
+
for (const segment of field.split('.')) {
|
|
1605
|
+
if (!cursor || typeof cursor !== 'object' || !Object.prototype.hasOwnProperty.call(cursor, segment))
|
|
1606
|
+
return undefined;
|
|
1607
|
+
cursor = cursor[segment];
|
|
1608
|
+
}
|
|
1609
|
+
return cursor;
|
|
1610
|
+
}
|
|
1611
|
+
function readFileText(file) {
|
|
1612
|
+
try {
|
|
1613
|
+
return fs.readFileSync(file, 'utf8');
|
|
1614
|
+
}
|
|
1615
|
+
catch (error) {
|
|
1616
|
+
if (error?.code === 'ENOENT')
|
|
1617
|
+
return null;
|
|
1618
|
+
throw error;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
function jsonText(value) {
|
|
1622
|
+
return JSON.stringify(value, null, 2) + '\n';
|
|
1623
|
+
}
|