@sparkelf/dsh-dataops-managed 0.3.21 → 0.3.22

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/lib/index.js CHANGED
@@ -5,10 +5,34 @@ import * as McpClient from "@sparkelf/dsh-plugin-mcp-credentials";
5
5
  import { BUNDLED_SKILL_RANK } from "@deepseek-ai/dsh-skill";
6
6
  import z from "@deepseek-ai/schemastery";
7
7
  import { defineTool } from "@deepseek-ai/dsh-tools";
8
- import { randomUUID } from "node:crypto";
9
8
  import { dirname, join } from "node:path";
9
+ import { randomUUID } from "node:crypto";
10
10
  //#region src/auth-state.ts
11
11
  let state = "unknown";
12
+ const listeners = /* @__PURE__ */ new Set();
13
+ /**
14
+ * Observe every recorded transition.
15
+ *
16
+ * The tool manager uses this to drop its disclosure as soon as any DataOps call reports a 401:
17
+ * the scope that produced the tool list can no longer be trusted, and waiting for another
18
+ * explicit sync would leave tools offered against an ended session. Listener failures are
19
+ * contained so a broken observer cannot turn a credential observation into a failed call.
20
+ * @param listener - Called with the new state after it is recorded.
21
+ * @returns The exact unsubscriber.
22
+ */
23
+ const onAuthStateChange = (listener) => {
24
+ listeners.add(listener);
25
+ return () => {
26
+ listeners.delete(listener);
27
+ };
28
+ };
29
+ /** Record one observation and notify observers. */
30
+ const setState = (next) => {
31
+ state = next;
32
+ for (const listener of listeners) try {
33
+ listener(next);
34
+ } catch {}
35
+ };
12
36
  /**
13
37
  * Record that a DataOps call was accepted.
14
38
  *
@@ -16,11 +40,11 @@ let state = "unknown";
16
40
  * says the session is fine and the permission is not, so it must not raise the sign-in prompt.
17
41
  */
18
42
  const noteAccepted = () => {
19
- state = "valid";
43
+ setState("valid");
20
44
  };
21
45
  /** Record that DataOps refused the credential, which means the session is over. */
22
46
  const noteExpired = () => {
23
- state = "expired";
47
+ setState("expired");
24
48
  };
25
49
  /**
26
50
  * Read the current observation.
@@ -28,6 +52,12 @@ const noteExpired = () => {
28
52
  * @returns `unknown` before any call has completed, then what the last one said.
29
53
  */
30
54
  const currentAuthState = () => state;
55
+ /**
56
+ * Test-only reset, so one case cannot leak its observation into the next.
57
+ */
58
+ const resetAuthState = () => {
59
+ setState("unknown");
60
+ };
31
61
  //#endregion
32
62
  //#region src/model-settings.ts
33
63
  /** Profile entry id whose Config holds the model providers. */
@@ -811,63 +841,401 @@ var ReviewApi = class {
811
841
  }
812
842
  };
813
843
  //#endregion
814
- //#region src/tools.ts
815
- const open = {
816
- type: "object",
817
- additionalProperties: true
844
+ //#region src/tool-scope.ts
845
+ /**
846
+ * Tool-to-capability disclosure map.
847
+ *
848
+ * `review_notifications_list/read` intentionally carry no policy: every authenticated principal
849
+ * owns their own notification inbox. `dataops_api_docs` is gated by the role itself because the
850
+ * backend grants the OpenAPI reader to 超级管理员 by default; an administrator who happens to hold
851
+ * `developer:openapi:read` is still outside that role and does not receive the tool.
852
+ */
853
+ const AI_TOOL_ACCESS = {
854
+ skill_list: { permission: "skill:read" },
855
+ skill_get: { permission: "skill:read" },
856
+ skill_submit: { permission: "skill:submit" },
857
+ skill_install: { permission: "skill:install" },
858
+ skill_uninstall: { permission: "skill:install" },
859
+ skill_enable: { permission: "skill:manage" },
860
+ skill_disable: { permission: "skill:manage" },
861
+ skill_unshare: { permission: "skill:manage" },
862
+ skill_delete: { permission: "skill:manage" },
863
+ review_scan: {
864
+ permission: "skill:read",
865
+ reviewer: true
866
+ },
867
+ review_decide: {
868
+ permission: "skill:review:decide",
869
+ reviewer: true
870
+ },
871
+ review_notifications_list: {},
872
+ review_notifications_read: {},
873
+ dataops_api_docs: { superAdminOnly: true }
818
874
  };
819
- const render = (_args, value) => [{
820
- type: "text",
821
- text: JSON.stringify(value, null, 2)
822
- }];
875
+ /** Every managed tool name, in the order the manager considers them. */
876
+ const AI_TOOL_NAMES = Object.keys(AI_TOOL_ACCESS);
877
+ /**
878
+ * Backend permission aliases.
879
+ *
880
+ * The DataOps role matrix stores the capability family as `skill:capability:*`; this task's
881
+ * disclosure contract names them `skill:*`. Accepting both keeps the plugin working against
882
+ * either spelling without asking the backend to rename a published key.
883
+ */
884
+ const PERMISSION_ALIASES = {
885
+ "skill:read": ["skill:read", "skill:capability:read"],
886
+ "skill:submit": ["skill:submit", "skill:capability:submit"],
887
+ "skill:install": ["skill:install", "skill:capability:install"],
888
+ "skill:manage": ["skill:manage", "skill:capability:manage"],
889
+ "skill:review:decide": ["skill:review:decide"]
890
+ };
891
+ const SUPER_ADMIN = "超级管理员";
892
+ const ADMIN = "管理员";
893
+ const DEPARTMENT_ADMIN = "部门管理员";
894
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
895
+ const strings$1 = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : void 0;
823
896
  const roleSet = (scope) => new Set(scope.roleCodes ?? []);
824
- const isSuper = (scope) => roleSet(scope).has("超级管理员") || scope.primaryRole === "超级管理员";
825
- const isAdmin = (scope) => isSuper(scope) || roleSet(scope).has("管理员") || scope.primaryRole === "管理员";
826
- const isDeptAdmin = (scope) => roleSet(scope).has("部门管理员") || scope.primaryRole === "部门管理员";
827
- const canReview = (scope) => scope.review?.canReview === true || isAdmin(scope) || isDeptAdmin(scope);
828
- const forbidden = (message) => /* @__PURE__ */ new Error("Forbidden (403): " + message);
829
- /** Permission codes from the context, tolerating either field name during rollout. */
830
- const permissionsOf = (scope) => new Set(scope.permissions ?? scope.permissionKeys ?? []);
897
+ /** Whether the context carries the stable super-administrator role. */
898
+ const isSuperAdmin = (scope) => roleSet(scope).has(SUPER_ADMIN) || scope.primaryRole === SUPER_ADMIN || (scope.systemKeys ?? []).includes("SUPER_ADMIN");
899
+ /** Whether the context carries an administrator or super-administrator role. */
900
+ const isAdmin = (scope) => isSuperAdmin(scope) || roleSet(scope).has(ADMIN) || scope.primaryRole === ADMIN || (scope.systemKeys ?? []).includes("ADMIN");
901
+ /** Whether the context carries a department-administrator role. */
902
+ const isDepartmentAdmin = (scope) => roleSet(scope).has(DEPARTMENT_ADMIN) || scope.primaryRole === DEPARTMENT_ADMIN || (scope.systemKeys ?? []).includes("DEPT_ADMIN");
903
+ /**
904
+ * Whether the caller may run review operations.
905
+ *
906
+ * The current backend returns `canReview` at the top level; the nested `review` shape is
907
+ * accepted for older contexts. Role membership is a fallback, not an override.
908
+ */
909
+ const canReview = (scope) => scope.canReview === true || scope.review?.canReview === true || isAdmin(scope) || isDepartmentAdmin(scope);
910
+ /** The effective permission list, or `undefined` for a context that predates permission keys. */
911
+ const permissionList = (scope) => scope.permissions ?? scope.permissionKeys;
831
912
  /**
832
- * Whether the caller holds one `skill:*` capability.
913
+ * Whether the caller holds one capability.
833
914
  *
834
- * The context is the authority once it carries a permission list; before task-70 ships,
835
- * the role-name fallback keeps the existing four-role behavior. A present-but-empty list is
836
- * a denial, not a reason to fall back: an authenticated user with no keys must not inherit
837
- * administrator powers from their role label alone.
915
+ * A context that carries a permission list is authoritative. Before the list existed, the
916
+ * four-role fallback kept the old behavior; it is only used when neither spelling is present.
838
917
  */
839
- const hasSkillPermission = (scope, key) => {
840
- const permissions = permissionsOf(scope);
841
- if (scope.permissions !== void 0 || scope.permissionKeys !== void 0) return permissions.has(key);
918
+ const hasPermission = (scope, key) => {
919
+ const permissions = permissionList(scope);
920
+ if (permissions !== void 0) {
921
+ const set = new Set(permissions);
922
+ return (PERMISSION_ALIASES[key] ?? [key]).some((alias) => set.has(alias));
923
+ }
842
924
  if (isAdmin(scope)) return true;
843
- if (isDeptAdmin(scope)) return key !== "skill:review:global";
925
+ if (isDepartmentAdmin(scope)) return key !== "skill:review:global";
926
+ if (roleSet(scope).size === 0) return false;
844
927
  return key === "skill:read" || key === "skill:install" || key === "skill:submit";
845
928
  };
846
- const requireSkillPermission = (scope, key) => {
847
- if (!hasSkillPermission(scope, key)) throw forbidden("missing permission " + key);
848
- };
849
- /** Fetch and validate the caller's DataOps scope. */
850
- async function loadScope(api) {
851
- const result = await api.request("/api/ai/review/context");
852
- if (result.status === 401) throw new Error("Unauthorized (401): DataOps session expired");
853
- if (result.status === 403) throw forbidden("no review context");
854
- if (result.status !== 200) throw new Error("review context failed: HTTP " + result.status.toString());
855
- const body = result.body;
856
- if (body === null || typeof body.roleCodes !== "object" || !Array.isArray(body.roleCodes)) throw new Error("review context is missing roleCodes");
857
- return body;
929
+ /**
930
+ * Whether one tool should be registered for this scope.
931
+ *
932
+ * Every check is disclosure metadata, not an execution gate: a visible tool still lets DataOps
933
+ * answer with 403 when the underlying grant changed between a scope sync and the call.
934
+ */
935
+ function isToolVisible(name, scope) {
936
+ const policy = AI_TOOL_ACCESS[name];
937
+ if (policy.superAdminOnly === true && !isSuperAdmin(scope)) return false;
938
+ if (policy.reviewer === true && !canReview(scope)) return false;
939
+ if (policy.permission !== void 0 && !hasPermission(scope, policy.permission)) return false;
940
+ return true;
941
+ }
942
+ /**
943
+ * Validate one `/api/ai/review/context` body.
944
+ *
945
+ * A body without `roleCodes` is not a usable scope: treating it as "no roles" would let the
946
+ * role-name fallback hand an ordinary capability set to an answer this plugin cannot interpret.
947
+ * @param body - Parsed JSON from the context endpoint.
948
+ * @returns The normalized scope, or undefined when the answer is not a context document.
949
+ */
950
+ function normalizeScope(body) {
951
+ if (!isRecord$1(body)) return void 0;
952
+ const roleCodes = strings$1(body.roleCodes);
953
+ if (roleCodes === void 0) return void 0;
954
+ const roleSystemKeys = (Array.isArray(body.roles) ? body.roles : []).flatMap((entry) => {
955
+ if (!isRecord$1(entry)) return [];
956
+ return typeof entry.systemKey === "string" ? [entry.systemKey] : [];
957
+ });
958
+ const explicitSystemKeys = strings$1(body.systemKeys) ?? [];
959
+ const systemKeys = Array.from(/* @__PURE__ */ new Set([...explicitSystemKeys, ...roleSystemKeys]));
960
+ const permissions = strings$1(body.permissions) ?? (Array.isArray(body.permissionKeys) ? strings$1(body.permissionKeys) : void 0);
961
+ const review = isRecord$1(body.review) ? {
962
+ ...typeof body.review.canReview === "boolean" ? { canReview: body.review.canReview } : {},
963
+ ...typeof body.review.canReviewGlobal === "boolean" ? { canReviewGlobal: body.review.canReviewGlobal } : {},
964
+ ...typeof body.review.canManagePermissions === "boolean" ? { canManagePermissions: body.review.canManagePermissions } : {}
965
+ } : void 0;
966
+ return {
967
+ ...typeof body.userId === "string" ? { userId: body.userId } : {},
968
+ ...typeof body.username === "string" ? { username: body.username } : {},
969
+ roleCodes,
970
+ ...typeof body.primaryRole === "string" ? { primaryRole: body.primaryRole } : {},
971
+ ...systemKeys.length === 0 ? {} : { systemKeys },
972
+ ...typeof body.departmentCode === "string" || body.departmentCode === null ? { departmentCode: body.departmentCode } : {},
973
+ ...permissions === void 0 ? {} : { permissions },
974
+ ...isRecord$1(body.dataScope) ? { dataScope: body.dataScope } : {},
975
+ ...review === void 0 ? {} : { review },
976
+ ...typeof body.canReview === "boolean" ? { canReview: body.canReview } : {},
977
+ ...typeof body.canReviewGlobal === "boolean" ? { canReviewGlobal: body.canReviewGlobal } : {}
978
+ };
979
+ }
980
+ //#endregion
981
+ //#region src/api-docs.ts
982
+ /** Raised when the upstream body is not an OpenAPI document this module can search. */
983
+ var ApiDocsUnavailableError = class extends Error {};
984
+ /** HTTP methods a path item may declare, in a stable presentation order. */
985
+ const HTTP_METHODS = [
986
+ "get",
987
+ "post",
988
+ "put",
989
+ "patch",
990
+ "delete",
991
+ "head",
992
+ "options",
993
+ "trace"
994
+ ];
995
+ /** Cap on returned operations, so one broad query cannot flood a model turn. */
996
+ const MAX_RESULTS = 50;
997
+ /** Cap on projected schema properties. */
998
+ const MAX_PROPERTIES = 25;
999
+ /** Cap on projected parameters. */
1000
+ const MAX_PARAMETERS = 25;
1001
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1002
+ const strings = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : void 0;
1003
+ const normalized = (value) => (value ?? "").trim().toLowerCase();
1004
+ /** Split a query into whitespace-separated keywords; an empty query matches everything. */
1005
+ const keywords = (query) => normalized(query).split(/\s+/u).filter((token) => token !== "");
1006
+ /** Resolve one local JSON pointer such as `#/components/schemas/ReviewQuery`. */
1007
+ function resolvePointer(document, ref) {
1008
+ if (!ref.startsWith("#/")) return void 0;
1009
+ let current = document;
1010
+ for (const raw of ref.slice(2).split("/")) {
1011
+ if (!isRecord(current)) return void 0;
1012
+ const key = decodeURIComponent(raw).replace(/~1/gu, "/").replace(/~0/gu, "~");
1013
+ current = current[key];
1014
+ }
1015
+ return current;
1016
+ }
1017
+ /** Resolve one `$ref` object one level, leaving a non-reference value unchanged. */
1018
+ function dereference(value, document, depth = 0) {
1019
+ if (!isRecord(value) || typeof value.$ref !== "string" || depth >= 4) return value;
1020
+ return dereference(resolvePointer(document, value.$ref), document, depth + 1);
1021
+ }
1022
+ /** The final segment of a local reference, used as a stable type label. */
1023
+ function refName(ref) {
1024
+ const segment = ref.split("/").pop() ?? ref;
1025
+ return decodeURIComponent(segment).replace(/~1/gu, "/").replace(/~0/gu, "~");
1026
+ }
1027
+ /** A short type label for one schema, resolving one local reference chain when useful. */
1028
+ function schemaType(schema, document, depth = 0) {
1029
+ const resolved = dereference(schema, document, depth);
1030
+ if (!isRecord(resolved)) return "unknown";
1031
+ if (typeof resolved.type === "string") {
1032
+ if (resolved.type === "array" && isRecord(resolved.items)) return schemaType(resolved.items, document, depth + 1) + "[]";
1033
+ return resolved.type;
1034
+ }
1035
+ if (isRecord(resolved.properties)) return "object";
1036
+ if (resolved.items !== void 0) return "array";
1037
+ if (typeof resolved.$ref === "string") return refName(resolved.$ref);
1038
+ return "unknown";
1039
+ }
1040
+ /** Project one schema into a bounded summary without following cycles forever. */
1041
+ function summarizeSchema(schema, document, depth = 0) {
1042
+ if (!isRecord(schema)) return null;
1043
+ const ref = typeof schema.$ref === "string" ? refName(schema.$ref) : void 0;
1044
+ const resolved = dereference(schema, document, depth);
1045
+ if (!isRecord(resolved)) return ref === void 0 ? null : { ref };
1046
+ const required = strings(resolved.required) ?? [];
1047
+ const requiredSet = new Set(required);
1048
+ const properties = isRecord(resolved.properties) ? Object.entries(resolved.properties).slice(0, MAX_PROPERTIES).map(([name, property]) => ({
1049
+ name,
1050
+ type: schemaType(property, document, depth + 1),
1051
+ required: requiredSet.has(name)
1052
+ })) : void 0;
1053
+ const type = typeof resolved.type === "string" ? resolved.type : isRecord(resolved.properties) ? "object" : resolved.items === void 0 ? void 0 : "array";
1054
+ return {
1055
+ ...type === void 0 ? {} : { type },
1056
+ ...ref === void 0 ? {} : { ref },
1057
+ ...required.length === 0 ? {} : { required },
1058
+ ...properties === void 0 ? {} : { properties }
1059
+ };
1060
+ }
1061
+ /** Merge path-level and operation-level parameters; the operation wins on name+location. */
1062
+ function summarizeParameters(pathItem, operation, document) {
1063
+ const merged = /* @__PURE__ */ new Map();
1064
+ for (const source of [pathItem.parameters, operation.parameters]) {
1065
+ if (!Array.isArray(source)) continue;
1066
+ for (const raw of source) {
1067
+ const parameter = dereference(raw, document);
1068
+ if (!isRecord(parameter)) continue;
1069
+ if (typeof parameter.name !== "string" || typeof parameter.in !== "string") continue;
1070
+ const schema = parameter.schema !== void 0 ? parameter.schema : parameter;
1071
+ merged.set(parameter.in + ":" + parameter.name, {
1072
+ name: parameter.name,
1073
+ in: parameter.in,
1074
+ required: parameter.required === true,
1075
+ type: schemaType(schema, document)
1076
+ });
1077
+ }
1078
+ }
1079
+ return [...merged.values()].slice(0, MAX_PARAMETERS);
1080
+ }
1081
+ /** Describe the request body's chosen media type and bounded schema fields. */
1082
+ function summarizeRequestBody(operation, document) {
1083
+ const body = dereference(operation.requestBody, document);
1084
+ if (!isRecord(body) || !isRecord(body.content)) return null;
1085
+ const contentTypes = Object.keys(body.content);
1086
+ if (contentTypes.length === 0) return null;
1087
+ const preferred = contentTypes.includes("application/json") ? "application/json" : contentTypes[0];
1088
+ const media = body.content[preferred];
1089
+ const schema = isRecord(media) ? summarizeSchema(media.schema, document) : null;
1090
+ return {
1091
+ required: body.required === true,
1092
+ contentTypes,
1093
+ schema
1094
+ };
1095
+ }
1096
+ /** The security requirement list that applies to one operation. */
1097
+ function summarizeSecurity(operation, document) {
1098
+ return (Array.isArray(operation.security) ? operation.security : Array.isArray(document.security) ? document.security : []).filter(isRecord);
858
1099
  }
1100
+ /** Read one operation into its compact projection. */
1101
+ function compactOperation(method, path, pathItem, operation, document) {
1102
+ return {
1103
+ method: method.toUpperCase(),
1104
+ path,
1105
+ operationId: typeof operation.operationId === "string" ? operation.operationId : null,
1106
+ summary: typeof operation.summary === "string" ? operation.summary : null,
1107
+ tags: strings(operation.tags) ?? [],
1108
+ parameters: summarizeParameters(pathItem, operation, document),
1109
+ requestBody: summarizeRequestBody(operation, document),
1110
+ security: summarizeSecurity(operation, document)
1111
+ };
1112
+ }
1113
+ /** Whether one operation passes every supplied filter. */
1114
+ function matches(operation, filters, tokens) {
1115
+ if (filters.tag !== "" && !operation.tags.some((tag) => tag.toLowerCase() === filters.tag)) return false;
1116
+ if (filters.path !== "" && !operation.path.toLowerCase().includes(filters.path)) return false;
1117
+ if (filters.operationId !== "" && !(operation.operationId ?? "").toLowerCase().includes(filters.operationId)) return false;
1118
+ if (filters.method !== "" && operation.method !== filters.method) return false;
1119
+ if (tokens.length === 0) return true;
1120
+ const haystack = [
1121
+ operation.path,
1122
+ operation.operationId ?? "",
1123
+ operation.summary ?? "",
1124
+ operation.description,
1125
+ operation.tags.join(" ")
1126
+ ].join(" ").toLowerCase();
1127
+ return tokens.every((token) => haystack.includes(token));
1128
+ }
1129
+ /** Relevance used only to order matches; equal scores keep the document order. */
1130
+ function scoreOf(operation, tokens) {
1131
+ if (tokens.length === 0) return 0;
1132
+ let score = 0;
1133
+ const operationId = (operation.operationId ?? "").toLowerCase();
1134
+ const path = operation.path.toLowerCase();
1135
+ const summary = (operation.summary ?? "").toLowerCase();
1136
+ const tags = operation.tags.map((tag) => tag.toLowerCase());
1137
+ const description = operation.description.toLowerCase();
1138
+ for (const token of tokens) {
1139
+ if (operationId === token) score += 5;
1140
+ else if (operationId.includes(token)) score += 3;
1141
+ if (path.includes(token)) score += 2;
1142
+ if (summary.includes(token)) score += 1;
1143
+ if (tags.some((tag) => tag.includes(token))) score += 1;
1144
+ if (description.includes(token)) score += 2;
1145
+ }
1146
+ return score;
1147
+ }
1148
+ /**
1149
+ * Search one parsed OpenAPI document.
1150
+ * @param document - Parsed `/api/openapi.json` body.
1151
+ * @param filters - Keyword, tag, path, operationId and method filters.
1152
+ * @returns Matching operations, bounded and ordered by relevance.
1153
+ * @throws ApiDocsUnavailableError when the document has no searchable `paths` object.
1154
+ */
1155
+ function searchOpenApiDocument(document, filters = {}) {
1156
+ if (!isRecord(document)) throw new ApiDocsUnavailableError("DataOps API docs are unavailable: the response is not an OpenAPI document");
1157
+ if (!isRecord(document.paths)) throw new ApiDocsUnavailableError("DataOps API docs are unavailable: the document has no paths object");
1158
+ const wanted = {
1159
+ tag: normalized(filters.tag),
1160
+ path: normalized(filters.path),
1161
+ operationId: normalized(filters.operation_id),
1162
+ method: normalized(filters.method)
1163
+ };
1164
+ const tokens = keywords(filters.query);
1165
+ const matched = [];
1166
+ let index = 0;
1167
+ for (const [path, rawPathItem] of Object.entries(document.paths)) {
1168
+ const pathItem = dereference(rawPathItem, document);
1169
+ if (!isRecord(pathItem)) continue;
1170
+ for (const method of HTTP_METHODS) {
1171
+ const operation = pathItem[method];
1172
+ if (!isRecord(operation)) continue;
1173
+ const candidate = {
1174
+ method,
1175
+ path,
1176
+ operationId: typeof operation.operationId === "string" ? operation.operationId : null,
1177
+ summary: typeof operation.summary === "string" ? operation.summary : null,
1178
+ tags: strings(operation.tags) ?? [],
1179
+ description: typeof operation.description === "string" ? operation.description : "",
1180
+ pathItem,
1181
+ operation,
1182
+ index
1183
+ };
1184
+ index += 1;
1185
+ if (!matches(candidate, wanted, tokens)) continue;
1186
+ matched.push({
1187
+ operation: candidate,
1188
+ score: scoreOf(candidate, tokens)
1189
+ });
1190
+ }
1191
+ }
1192
+ matched.sort((left, right) => right.score - left.score || left.operation.index - right.operation.index);
1193
+ const results = matched.slice(0, MAX_RESULTS).map(({ operation }) => compactOperation(operation.method, operation.path, operation.pathItem, operation.operation, document));
1194
+ const info = isRecord(document.info) ? document.info : {};
1195
+ return {
1196
+ title: typeof info.title === "string" ? info.title : null,
1197
+ version: typeof info.version === "string" ? info.version : null,
1198
+ openapi: typeof document.openapi === "string" ? document.openapi : typeof document.swagger === "string" ? "swagger " + document.swagger : null,
1199
+ total: matched.length,
1200
+ returned: results.length,
1201
+ truncated: matched.length > results.length,
1202
+ results
1203
+ };
1204
+ }
1205
+ //#endregion
1206
+ //#region src/tool-output.ts
1207
+ /** An open object schema: tools declare their canonical shape in code and docs. */
1208
+ const OPEN_OBJECT_SCHEMA = {
1209
+ type: "object",
1210
+ additionalProperties: true
1211
+ };
1212
+ /** Render one canonical value as pretty JSON text. */
1213
+ const renderJson = (_args, value) => [{
1214
+ type: "text",
1215
+ text: JSON.stringify(value, null, 2)
1216
+ }];
1217
+ //#endregion
1218
+ //#region src/tools.ts
859
1219
  /** Parse one plaza response body into rows without trusting its shape. */
860
1220
  function rowsOf(body) {
861
1221
  if (Array.isArray(body)) return body.filter((row) => typeof row === "object" && row !== null);
862
1222
  if (typeof body === "object" && body !== null && Array.isArray(body.skills)) return body.skills.filter((row) => typeof row === "object" && row !== null);
863
1223
  return [];
864
1224
  }
865
- /** Register the skill-centre and review tools. */
866
- function registerAiManagementTools(ctx, reviewApi) {
867
- const disposers = [];
868
- const register = (tool) => {
869
- disposers.push(ctx.tools.register(tool));
870
- };
1225
+ const forbidden = (message) => /* @__PURE__ */ new Error("Forbidden (403): " + message);
1226
+ /**
1227
+ * Build every managed tool definition.
1228
+ *
1229
+ * The definitions are built once; `readScope` is a live getter so a tool that survives a role
1230
+ * change reports the scope the manager currently holds instead of the one it was registered
1231
+ * with. An undefined scope can only be observed by an in-flight call during disposal, where the
1232
+ * safe behavior is to show only the public catalogue.
1233
+ * @param ctx - Host context used for local skills, approval and audit.
1234
+ * @param reviewApi - Same-origin bridge holding the workspace JWT.
1235
+ * @param readScope - Live accessor for the manager's current scope.
1236
+ * @returns One definition per managed tool name.
1237
+ */
1238
+ function buildAiManagementTools(ctx, reviewApi, readScope) {
871
1239
  const approve = async (tool, exec, reason) => {
872
1240
  if (exec.agent === void 0) throw new Error(tool + " requires an initiating agent");
873
1241
  const outcome = await ctx.approval.request({
@@ -900,7 +1268,6 @@ function registerAiManagementTools(ctx, reviewApi) {
900
1268
  return result.body;
901
1269
  };
902
1270
  const listSkills = async (scope, includeLocal) => {
903
- requireSkillPermission(scope, "skill:read");
904
1271
  const mine = rowsOf(await call("/api/ai/skill-plaza/skills?scope=mine"));
905
1272
  const publicRows = rowsOf(await call("/api/ai/skill-plaza/skills"));
906
1273
  const admin = isAdmin(scope) ? rowsOf(await call("/api/ai/skill-plaza/admin/skills")) : [];
@@ -927,389 +1294,714 @@ function registerAiManagementTools(ctx, reviewApi) {
927
1294
  },
928
1295
  canReview: canReview(scope),
929
1296
  isAdmin: isAdmin(scope),
930
- isDepartmentAdmin: isDeptAdmin(scope),
1297
+ isDepartmentAdmin: isDepartmentAdmin(scope),
931
1298
  departmentCode: scope.departmentCode ?? null,
932
1299
  skills: rows
933
1300
  };
934
1301
  };
935
- register(defineTool({
936
- name: "skill_list",
937
- description: "List skills in the DataOps skill centre: local workspace skills plus the caller's pending/rejected submissions and the approved plaza entries they may install. Scope follows the caller role (department admins see their department; ordinary users see their own and approved entries). Read-only.",
938
- parameters: { include_local: {
939
- type: "boolean",
940
- description: "Include local workspace skills (default true)."
941
- } },
942
- output: {
943
- schema: open,
944
- render
1302
+ /**
1303
+ * Build the enable/disable pair.
1304
+ *
1305
+ * The two tools share one body so their approval reason, audit detail and endpoint stay in
1306
+ * step; only the verb and the boolean they send differ.
1307
+ */
1308
+ function defineSkillStatusTool(enabled) {
1309
+ const name = enabled ? "skill_enable" : "skill_disable";
1310
+ return defineTool({
1311
+ name,
1312
+ description: (enabled ? "Enable" : "Disable") + " one plaza skill for distribution. Administrator only. Requires user approval; audited.",
1313
+ parameters: { skill_id: {
1314
+ type: "number",
1315
+ required: true,
1316
+ description: "Plaza skill id."
1317
+ } },
1318
+ output: {
1319
+ schema: OPEN_OBJECT_SCHEMA,
1320
+ render: renderJson
1321
+ },
1322
+ execute: async (args, exec) => {
1323
+ await approve(name, exec, (enabled ? "Enable" : "Disable") + " skill " + args.skill_id.toString());
1324
+ await call("/api/ai/skill-plaza/admin/skills/" + args.skill_id.toString() + "/status", {
1325
+ method: "PATCH",
1326
+ body: JSON.stringify({ enabled })
1327
+ });
1328
+ audit(name, exec, {
1329
+ skillId: args.skill_id,
1330
+ enabled
1331
+ });
1332
+ return {
1333
+ skillId: args.skill_id,
1334
+ enabled
1335
+ };
1336
+ }
1337
+ });
1338
+ }
1339
+ return [
1340
+ {
1341
+ name: "skill_list",
1342
+ definition: defineTool({
1343
+ name: "skill_list",
1344
+ description: "List skills in the DataOps skill centre: local workspace skills plus the caller's pending/rejected submissions and the approved plaza entries they may install. Scope follows the caller role (department admins see their department; ordinary users see their own and approved entries). Read-only.",
1345
+ parameters: { include_local: {
1346
+ type: "boolean",
1347
+ description: "Include local workspace skills (default true)."
1348
+ } },
1349
+ output: {
1350
+ schema: OPEN_OBJECT_SCHEMA,
1351
+ render: renderJson
1352
+ },
1353
+ execute: async (args) => {
1354
+ return await listSkills(readScope() ?? {}, args.include_local !== false);
1355
+ }
1356
+ })
945
1357
  },
946
- execute: async (args) => {
947
- const scope = await loadScope(reviewApi);
948
- return listSkills(scope, args.include_local !== false);
949
- }
950
- }));
951
- register(defineTool({
952
- name: "skill_get",
953
- description: "Read one skill-centre entry by id: description, instruction, review status, review note, risk report and visibility. Only entries visible to the caller can be read.",
954
- parameters: { skill_id: {
955
- type: "number",
956
- required: true,
957
- description: "Skill/plaza id from skill_list."
958
- } },
959
- output: {
960
- schema: open,
961
- render
1358
+ {
1359
+ name: "skill_get",
1360
+ definition: defineTool({
1361
+ name: "skill_get",
1362
+ description: "Read one skill-centre entry by id: description, instruction, review status, review note, risk report and visibility. Only entries visible to the caller can be read.",
1363
+ parameters: { skill_id: {
1364
+ type: "number",
1365
+ required: true,
1366
+ description: "Skill/plaza id from skill_list."
1367
+ } },
1368
+ output: {
1369
+ schema: OPEN_OBJECT_SCHEMA,
1370
+ render: renderJson
1371
+ },
1372
+ execute: async (args) => {
1373
+ const found = (await listSkills(readScope() ?? {}, false)).skills.find((row) => row.id === args.skill_id);
1374
+ if (found === void 0) throw new Error("Skill not found or not visible: " + args.skill_id.toString());
1375
+ return found;
1376
+ }
1377
+ })
962
1378
  },
963
- execute: async (args) => {
964
- const scope = await loadScope(reviewApi);
965
- const found = (await listSkills(scope, false)).skills.find((row) => row.id === args.skill_id);
966
- if (found === void 0) throw new Error("Skill not found or not visible: " + args.skill_id.toString());
967
- return found;
968
- }
969
- }));
970
- register(defineTool({
971
- name: "skill_submit",
972
- description: "Submit the caller's own skill for plaza review. When the workspace review switch is off DataOps approves immediately; otherwise the entry becomes pending. Requires user approval; audited.",
973
- parameters: { skill_id: {
974
- type: "number",
975
- required: true,
976
- description: "Own skill/plaza id."
977
- } },
978
- output: {
979
- schema: open,
980
- render
1379
+ {
1380
+ name: "skill_submit",
1381
+ definition: defineTool({
1382
+ name: "skill_submit",
1383
+ description: "Submit the caller's own skill for plaza review. When the workspace review switch is off DataOps approves immediately; otherwise the entry becomes pending. Requires user approval; audited.",
1384
+ parameters: { skill_id: {
1385
+ type: "number",
1386
+ required: true,
1387
+ description: "Own skill/plaza id."
1388
+ } },
1389
+ output: {
1390
+ schema: OPEN_OBJECT_SCHEMA,
1391
+ render: renderJson
1392
+ },
1393
+ execute: async (args, exec) => {
1394
+ await approve("skill_submit", exec, "Submit skill " + args.skill_id.toString() + " for review");
1395
+ const value = await call("/api/ai/skill-plaza/skills/" + args.skill_id.toString() + "/submit", {
1396
+ method: "POST",
1397
+ body: "{}"
1398
+ });
1399
+ audit("skill_submit", exec, { skillId: args.skill_id });
1400
+ return value;
1401
+ }
1402
+ })
981
1403
  },
982
- execute: async (args, exec) => {
983
- const scope = await loadScope(reviewApi);
984
- requireSkillPermission(scope, "skill:submit");
985
- await approve("skill_submit", exec, "Submit skill " + args.skill_id.toString() + " for review");
986
- const value = await call("/api/ai/skill-plaza/skills/" + args.skill_id.toString() + "/submit", {
987
- method: "POST",
988
- body: "{}"
989
- });
990
- audit("skill_submit", exec, { skillId: args.skill_id });
991
- return value;
992
- }
993
- }));
994
- register(defineTool({
995
- name: "skill_install",
996
- description: "Install one approved, visible plaza skill into the caller's workspace. Requires user approval; audited. The secret-free result reports the local skill key.",
997
- parameters: { skill_id: {
998
- type: "number",
999
- required: true,
1000
- description: "Plaza skill id."
1001
- } },
1002
- output: {
1003
- schema: open,
1004
- render
1404
+ {
1405
+ name: "skill_install",
1406
+ definition: defineTool({
1407
+ name: "skill_install",
1408
+ description: "Install one approved, visible plaza skill into the caller's workspace. Requires user approval; audited. The secret-free result reports the local skill key.",
1409
+ parameters: { skill_id: {
1410
+ type: "number",
1411
+ required: true,
1412
+ description: "Plaza skill id."
1413
+ } },
1414
+ output: {
1415
+ schema: OPEN_OBJECT_SCHEMA,
1416
+ render: renderJson
1417
+ },
1418
+ execute: async (args, exec) => {
1419
+ await approve("skill_install", exec, "Install skill " + args.skill_id.toString());
1420
+ const value = await call("/api/ai/skill-plaza/install", {
1421
+ method: "POST",
1422
+ body: JSON.stringify({ skillId: args.skill_id })
1423
+ });
1424
+ audit("skill_install", exec, { skillId: args.skill_id });
1425
+ return value;
1426
+ }
1427
+ })
1005
1428
  },
1006
- execute: async (args, exec) => {
1007
- const scope = await loadScope(reviewApi);
1008
- requireSkillPermission(scope, "skill:install");
1009
- await approve("skill_install", exec, "Install skill " + args.skill_id.toString());
1010
- const value = await call("/api/ai/skill-plaza/install", {
1011
- method: "POST",
1012
- body: JSON.stringify({ skillId: args.skill_id })
1013
- });
1014
- audit("skill_install", exec, { skillId: args.skill_id });
1015
- return value;
1016
- }
1017
- }));
1018
- register(defineTool({
1019
- name: "skill_uninstall",
1020
- description: "Uninstall one skill from the caller's own workspace only: deletes the current user's installation record. The plaza entry, its review status, visibility and enabled flag are unchanged and no “unpublished” notification is sent. Requires user approval; audited.",
1021
- parameters: { skill_id: {
1022
- type: "number",
1023
- required: true,
1024
- description: "Plaza skill id."
1025
- } },
1026
- output: {
1027
- schema: open,
1028
- render
1429
+ {
1430
+ name: "skill_uninstall",
1431
+ definition: defineTool({
1432
+ name: "skill_uninstall",
1433
+ description: "Uninstall one skill from the caller's own workspace only: deletes the current user's installation record. The plaza entry, its review status, visibility and enabled flag are unchanged and no “unpublished” notification is sent. Requires user approval; audited.",
1434
+ parameters: { skill_id: {
1435
+ type: "number",
1436
+ required: true,
1437
+ description: "Plaza skill id."
1438
+ } },
1439
+ output: {
1440
+ schema: OPEN_OBJECT_SCHEMA,
1441
+ render: renderJson
1442
+ },
1443
+ execute: async (args, exec) => {
1444
+ await approve("skill_uninstall", exec, "Uninstall skill " + args.skill_id.toString() + " from the current workspace");
1445
+ const result = await call("/api/ai/skill-plaza/skills/" + args.skill_id.toString() + "/install", { method: "DELETE" });
1446
+ audit("skill_uninstall", exec, {
1447
+ skillId: args.skill_id,
1448
+ scope: "installation-only",
1449
+ removed: result.removed ?? null
1450
+ });
1451
+ return {
1452
+ uninstalled: result.uninstalled === true,
1453
+ removed: typeof result.removed === "number" ? result.removed : 0
1454
+ };
1455
+ }
1456
+ })
1029
1457
  },
1030
- execute: async (args, exec) => {
1031
- const scope = await loadScope(reviewApi);
1032
- requireSkillPermission(scope, "skill:install");
1033
- await approve("skill_uninstall", exec, "Uninstall skill " + args.skill_id.toString() + " from the current workspace");
1034
- const result = await call("/api/ai/skill-plaza/skills/" + args.skill_id.toString() + "/install", { method: "DELETE" });
1035
- audit("skill_uninstall", exec, {
1036
- skillId: args.skill_id,
1037
- scope: "installation-only",
1038
- removed: result.removed ?? null
1039
- });
1040
- return {
1041
- uninstalled: result.uninstalled === true,
1042
- removed: typeof result.removed === "number" ? result.removed : 0
1043
- };
1044
- }
1045
- }));
1046
- const setEnabled = (enabled) => defineTool({
1047
- name: enabled ? "skill_enable" : "skill_disable",
1048
- description: (enabled ? "Enable" : "Disable") + " one plaza skill for distribution. Administrator only. Requires user approval; audited.",
1049
- parameters: { skill_id: {
1050
- type: "number",
1051
- required: true,
1052
- description: "Plaza skill id."
1053
- } },
1054
- output: {
1055
- schema: open,
1056
- render
1458
+ {
1459
+ name: "skill_enable",
1460
+ definition: defineSkillStatusTool(true)
1057
1461
  },
1058
- execute: async (args, exec) => {
1059
- const scope = await loadScope(reviewApi);
1060
- requireSkillPermission(scope, "skill:manage");
1061
- await approve(enabled ? "skill_enable" : "skill_disable", exec, (enabled ? "Enable" : "Disable") + " skill " + args.skill_id.toString());
1062
- await call("/api/ai/skill-plaza/admin/skills/" + args.skill_id.toString() + "/status", {
1063
- method: "PATCH",
1064
- body: JSON.stringify({ enabled })
1065
- });
1066
- audit(enabled ? "skill_enable" : "skill_disable", exec, {
1067
- skillId: args.skill_id,
1068
- enabled
1069
- });
1070
- return {
1071
- skillId: args.skill_id,
1072
- enabled
1073
- };
1074
- }
1075
- });
1076
- register(setEnabled(true));
1077
- register(setEnabled(false));
1078
- register(defineTool({
1079
- name: "skill_unshare",
1080
- description: "Stop sharing one plaza skill (soft unpublish): the entry and its installations remain, review status/visibility are kept, and DataOps sends an “unpublished” notification. Owners may unshare their own entries; 超级管理员/管理员 any entry; 部门管理员 their department. Requires user approval; audited.",
1081
- parameters: { skill_id: {
1082
- type: "number",
1083
- required: true,
1084
- description: "Plaza skill id."
1085
- } },
1086
- output: {
1087
- schema: open,
1088
- render
1462
+ {
1463
+ name: "skill_disable",
1464
+ definition: defineSkillStatusTool(false)
1089
1465
  },
1090
- execute: async (args, exec) => {
1091
- const scope = await loadScope(reviewApi);
1092
- requireSkillPermission(scope, "skill:manage");
1093
- if (!isAdmin(scope) && !isDeptAdmin(scope)) {
1094
- const found = (await listSkills(scope, false)).skills.find((row) => row.id === args.skill_id);
1095
- if (found === void 0 || found.sharedByMe !== true) throw forbidden("only the owner or an administrator may unshare this skill");
1096
- }
1097
- await approve("skill_unshare", exec, "Unshare skill " + args.skill_id.toString());
1098
- await call("/api/ai/skill-plaza/skills/" + args.skill_id.toString(), { method: "DELETE" });
1099
- audit("skill_unshare", exec, {
1100
- skillId: args.skill_id,
1101
- effect: "soft-unpublish"
1102
- });
1103
- return {
1104
- unshared: args.skill_id,
1105
- effect: "plaza-sharing-disabled"
1106
- };
1107
- }
1108
- }));
1109
- register(defineTool({
1110
- name: "skill_delete",
1111
- description: "Permanently delete one plaza skill. This is not unshare: the record is removed, DataOps sends a “deleted” notification (not “unpublished”), and the audit entry records the permanent action. Requires confirm=true as a second confirmation; owners may delete their own entries, 超级管理员/管理员 any entry, 部门管理员 their department. If installations still exist the backend returns 409 and the error is passed through. Requires user approval; audited.",
1112
- parameters: {
1113
- skill_id: {
1114
- type: "number",
1115
- required: true,
1116
- description: "Plaza skill id."
1117
- },
1118
- confirm: {
1119
- type: "boolean",
1120
- required: true,
1121
- description: "Must be true: explicitly confirms permanent deletion."
1122
- }
1466
+ {
1467
+ name: "skill_unshare",
1468
+ definition: defineTool({
1469
+ name: "skill_unshare",
1470
+ description: "Stop sharing one plaza skill (soft unpublish): the entry and its installations remain, review status/visibility are kept, and DataOps sends an “unpublished” notification. Owners may unshare their own entries; 超级管理员/管理员 any entry; 部门管理员 their department. Requires user approval; audited.",
1471
+ parameters: { skill_id: {
1472
+ type: "number",
1473
+ required: true,
1474
+ description: "Plaza skill id."
1475
+ } },
1476
+ output: {
1477
+ schema: OPEN_OBJECT_SCHEMA,
1478
+ render: renderJson
1479
+ },
1480
+ execute: async (args, exec) => {
1481
+ await approve("skill_unshare", exec, "Unshare skill " + args.skill_id.toString());
1482
+ await call("/api/ai/skill-plaza/skills/" + args.skill_id.toString(), { method: "DELETE" });
1483
+ audit("skill_unshare", exec, {
1484
+ skillId: args.skill_id,
1485
+ effect: "soft-unpublish"
1486
+ });
1487
+ return {
1488
+ unshared: args.skill_id,
1489
+ effect: "plaza-sharing-disabled"
1490
+ };
1491
+ }
1492
+ })
1123
1493
  },
1124
- output: {
1125
- schema: open,
1126
- render
1494
+ {
1495
+ name: "skill_delete",
1496
+ definition: defineTool({
1497
+ name: "skill_delete",
1498
+ description: "Permanently delete one plaza skill. This is not unshare: the record is removed, DataOps sends a “deleted” notification (not “unpublished”), and the audit entry records the permanent action. Requires confirm=true as a second confirmation; owners may delete their own entries, 超级管理员/管理员 any entry, 部门管理员 their department. If installations still exist the backend returns 409 and the error is passed through. Requires user approval; audited.",
1499
+ parameters: {
1500
+ skill_id: {
1501
+ type: "number",
1502
+ required: true,
1503
+ description: "Plaza skill id."
1504
+ },
1505
+ confirm: {
1506
+ type: "boolean",
1507
+ required: true,
1508
+ description: "Must be true: explicitly confirms permanent deletion."
1509
+ }
1510
+ },
1511
+ output: {
1512
+ schema: OPEN_OBJECT_SCHEMA,
1513
+ render: renderJson
1514
+ },
1515
+ execute: async (args, exec) => {
1516
+ if (args.confirm !== true) throw new Error("skill_delete requires confirm=true (permanent deletion)");
1517
+ await approve("skill_delete", exec, "Permanently delete skill " + args.skill_id.toString());
1518
+ await call("/api/ai/skill-plaza/skills/" + args.skill_id.toString() + "/permanent", { method: "DELETE" });
1519
+ audit("skill_delete", exec, {
1520
+ skillId: args.skill_id,
1521
+ permanent: true
1522
+ });
1523
+ return {
1524
+ deleted: args.skill_id,
1525
+ permanent: true
1526
+ };
1527
+ }
1528
+ })
1127
1529
  },
1128
- execute: async (args, exec) => {
1129
- if (args.confirm !== true) throw new Error("skill_delete requires confirm=true (permanent deletion)");
1130
- const scope = await loadScope(reviewApi);
1131
- requireSkillPermission(scope, "skill:manage");
1132
- if (!isAdmin(scope) && !isDeptAdmin(scope)) {
1133
- const found = (await listSkills(scope, false)).skills.find((row) => row.id === args.skill_id);
1134
- if (found === void 0 || found.sharedByMe !== true) throw forbidden("only the owner or an administrator may delete this skill");
1135
- }
1136
- await approve("skill_delete", exec, "Permanently delete skill " + args.skill_id.toString());
1137
- await call("/api/ai/skill-plaza/skills/" + args.skill_id.toString() + "/permanent", { method: "DELETE" });
1138
- audit("skill_delete", exec, {
1139
- skillId: args.skill_id,
1140
- permanent: true
1141
- });
1142
- return {
1143
- deleted: args.skill_id,
1144
- permanent: true
1145
- };
1146
- }
1147
- }));
1148
- register(defineTool({
1149
- name: "review_scan",
1150
- description: "Run the DataOps AI risk scan for skill and/or workflow ids. Reviewers only (超级管理员/管理员/部门管理员 with review capability); ordinary users receive 403. Read-only.",
1151
- parameters: {
1152
- skills: {
1153
- type: "array",
1154
- items: { type: "number" },
1155
- description: "Skill ids to scan."
1156
- },
1157
- workflows: {
1158
- type: "array",
1159
- items: { type: "string" },
1160
- description: "Workflow ids to scan."
1161
- }
1530
+ {
1531
+ name: "review_scan",
1532
+ definition: defineTool({
1533
+ name: "review_scan",
1534
+ description: "Run the DataOps AI risk scan for skill and/or workflow ids. Reviewers only; the tool is disclosed to principals with review capability and skill read access, and DataOps still enforces the permission on every call. Read-only.",
1535
+ parameters: {
1536
+ skills: {
1537
+ type: "array",
1538
+ items: { type: "number" },
1539
+ description: "Skill ids to scan."
1540
+ },
1541
+ workflows: {
1542
+ type: "array",
1543
+ items: { type: "string" },
1544
+ description: "Workflow ids to scan."
1545
+ }
1546
+ },
1547
+ output: {
1548
+ schema: OPEN_OBJECT_SCHEMA,
1549
+ render: renderJson
1550
+ },
1551
+ execute: async (args) => {
1552
+ return await call("/api/ai/review/scan", {
1553
+ method: "POST",
1554
+ body: JSON.stringify({
1555
+ skills: args.skills ?? [],
1556
+ workflows: args.workflows ?? []
1557
+ })
1558
+ });
1559
+ }
1560
+ })
1162
1561
  },
1163
- output: {
1164
- schema: open,
1165
- render
1562
+ {
1563
+ name: "review_decide",
1564
+ definition: defineTool({
1565
+ name: "review_decide",
1566
+ description: "Approve or reject skills/workflows. Requires authorization.confirmed=true (the administrator explicitly confirmed), a reviewer role, resource scope and the review-decide capability; DataOps enforces all of them on the call. Requires a second user approval at tool level and writes an audit entry.",
1567
+ parameters: {
1568
+ skills: {
1569
+ type: "array",
1570
+ items: { type: "number" },
1571
+ description: "Skill ids."
1572
+ },
1573
+ workflows: {
1574
+ type: "array",
1575
+ items: { type: "string" },
1576
+ description: "Workflow ids."
1577
+ },
1578
+ decision: {
1579
+ type: "string",
1580
+ required: true,
1581
+ description: "approve or reject."
1582
+ },
1583
+ note: {
1584
+ type: "string",
1585
+ description: "Review note (max 2000 chars)."
1586
+ },
1587
+ confirmed: {
1588
+ type: "boolean",
1589
+ required: true,
1590
+ description: "Must be true: the administrator confirmed this decision."
1591
+ }
1592
+ },
1593
+ output: {
1594
+ schema: OPEN_OBJECT_SCHEMA,
1595
+ render: renderJson
1596
+ },
1597
+ execute: async (args, exec) => {
1598
+ if (args.confirmed !== true) throw new Error("authorization.confirmed=true is required for review_decide");
1599
+ if (args.decision !== "approve" && args.decision !== "reject") throw new Error("decision must be approve or reject");
1600
+ if ((args.skills ?? []).length === 0 && (args.workflows ?? []).length === 0) throw new Error("at least one skill or workflow id is required");
1601
+ await approve("review_decide", exec, "Review " + args.decision + " for " + (args.skills ?? []).length.toString() + " skill(s) / " + (args.workflows ?? []).length.toString() + " workflow(s)");
1602
+ const value = await call("/api/ai/review/decide", {
1603
+ method: "POST",
1604
+ body: JSON.stringify({
1605
+ skills: args.skills ?? [],
1606
+ workflows: args.workflows ?? [],
1607
+ decision: args.decision,
1608
+ note: args.note ?? "",
1609
+ authorization: { confirmed: true }
1610
+ })
1611
+ });
1612
+ audit("review_decide", exec, {
1613
+ decision: args.decision,
1614
+ skills: args.skills ?? [],
1615
+ workflows: args.workflows ?? [],
1616
+ confirmed: true
1617
+ });
1618
+ return value;
1619
+ }
1620
+ })
1166
1621
  },
1167
- execute: async (args) => {
1168
- const scope = await loadScope(reviewApi);
1169
- requireSkillPermission(scope, "skill:read");
1170
- if (!canReview(scope)) throw forbidden("the current role cannot review");
1171
- return await call("/api/ai/review/scan", {
1172
- method: "POST",
1173
- body: JSON.stringify({
1174
- skills: args.skills ?? [],
1175
- workflows: args.workflows ?? []
1176
- })
1177
- });
1178
- }
1179
- }));
1180
- register(defineTool({
1181
- name: "review_decide",
1182
- description: "Approve or reject skills/workflows. Requires authorization.confirmed=true (the administrator explicitly confirmed), reviewer role and department scope; requires a second user approval at tool level and writes an audit entry. Ordinary users receive 403.",
1183
- parameters: {
1184
- skills: {
1185
- type: "array",
1186
- items: { type: "number" },
1187
- description: "Skill ids."
1188
- },
1189
- workflows: {
1190
- type: "array",
1191
- items: { type: "string" },
1192
- description: "Workflow ids."
1193
- },
1194
- decision: {
1195
- type: "string",
1196
- required: true,
1197
- description: "approve or reject."
1198
- },
1199
- note: {
1200
- type: "string",
1201
- description: "Review note (max 2000 chars)."
1202
- },
1203
- confirmed: {
1204
- type: "boolean",
1205
- required: true,
1206
- description: "Must be true: the administrator confirmed this decision."
1207
- }
1622
+ {
1623
+ name: "review_notifications_list",
1624
+ definition: defineTool({
1625
+ name: "review_notifications_list",
1626
+ description: "List the caller's review notifications and unread count. Read-only and always scoped to the current DataOps user.",
1627
+ parameters: {
1628
+ unread_only: {
1629
+ type: "boolean",
1630
+ description: "Only unread notifications."
1631
+ },
1632
+ limit: {
1633
+ type: "number",
1634
+ description: "Maximum rows (default 20)."
1635
+ }
1636
+ },
1637
+ output: {
1638
+ schema: OPEN_OBJECT_SCHEMA,
1639
+ render: renderJson
1640
+ },
1641
+ execute: async (args) => {
1642
+ const query = new URLSearchParams({
1643
+ unreadOnly: args.unread_only === true ? "true" : "false",
1644
+ limit: String(args.limit ?? 20)
1645
+ });
1646
+ return await call("/api/ai/review-notifications?" + query.toString());
1647
+ }
1648
+ })
1208
1649
  },
1209
- output: {
1210
- schema: open,
1211
- render
1650
+ {
1651
+ name: "review_notifications_read",
1652
+ definition: defineTool({
1653
+ name: "review_notifications_read",
1654
+ description: "Mark the caller's review notifications as read, either selected ids or all. Scoped to the current DataOps user; no secret is involved.",
1655
+ parameters: {
1656
+ ids: {
1657
+ type: "array",
1658
+ items: { type: "string" },
1659
+ description: "Notification ids to mark read."
1660
+ },
1661
+ all: {
1662
+ type: "boolean",
1663
+ description: "Mark every notification read."
1664
+ }
1665
+ },
1666
+ output: {
1667
+ schema: OPEN_OBJECT_SCHEMA,
1668
+ render: renderJson
1669
+ },
1670
+ execute: async (args, exec) => {
1671
+ const value = await call("/api/ai/review-notifications/read", {
1672
+ method: "POST",
1673
+ body: JSON.stringify({
1674
+ ids: args.ids ?? [],
1675
+ all: args.all === true
1676
+ })
1677
+ });
1678
+ audit("review_notifications_read", exec, {
1679
+ count: args.ids?.length ?? 0,
1680
+ all: args.all === true
1681
+ });
1682
+ return value;
1683
+ }
1684
+ })
1212
1685
  },
1213
- execute: async (args, exec) => {
1214
- const scope = await loadScope(reviewApi);
1215
- requireSkillPermission(scope, "skill:review:decide");
1216
- if (!canReview(scope)) throw forbidden("the current role cannot review");
1217
- if (args.confirmed !== true) throw new Error("authorization.confirmed=true is required for review_decide");
1218
- if (args.decision !== "approve" && args.decision !== "reject") throw new Error("decision must be approve or reject");
1219
- if ((args.skills ?? []).length === 0 && (args.workflows ?? []).length === 0) throw new Error("at least one skill or workflow id is required");
1220
- if (!isAdmin(scope) && isDeptAdmin(scope)) {
1221
- const rows = (await listSkills(scope, false)).skills;
1222
- const allowed = new Set(scope.dataScope?.departmentCodes ?? (scope.departmentCode === void 0 ? [] : [scope.departmentCode]));
1223
- for (const id of args.skills ?? []) {
1224
- const row = rows.find((item) => item.id === id);
1225
- if (row !== void 0 && (row.departmentCode === null || row.departmentCode === void 0)) requireSkillPermission(scope, "skill:review:global");
1226
- if (row !== void 0 && row.departmentCode !== null && row.departmentCode !== void 0 && !allowed.has(String(row.departmentCode))) throw forbidden("department administrators may only review their own department");
1686
+ {
1687
+ name: "dataops_api_docs",
1688
+ definition: defineTool({
1689
+ name: "dataops_api_docs",
1690
+ description: "Search the DataOps HTTP API reference (OpenAPI 3) for endpoint contracts. Super administrators only; read-only. Returns compact operations — method, path, operationId, summary, tags, parameters, request-body fields and security — filtered by keyword, tag, path, operationId or HTTP method. It only reads the fixed /api/openapi.json document and cannot execute arbitrary requests.",
1691
+ parameters: {
1692
+ query: {
1693
+ type: "string",
1694
+ description: "Case-insensitive keywords matched against path, operationId, summary, tags and description. All keywords must match."
1695
+ },
1696
+ tag: {
1697
+ type: "string",
1698
+ description: "Exact OpenAPI tag (case-insensitive)."
1699
+ },
1700
+ path: {
1701
+ type: "string",
1702
+ description: "Case-insensitive substring of the operation path."
1703
+ },
1704
+ operation_id: {
1705
+ type: "string",
1706
+ description: "Case-insensitive substring of the operationId."
1707
+ },
1708
+ method: {
1709
+ type: "string",
1710
+ description: "HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS or TRACE."
1711
+ }
1712
+ },
1713
+ output: {
1714
+ schema: OPEN_OBJECT_SCHEMA,
1715
+ render: renderJson
1716
+ },
1717
+ execute: async (args) => {
1718
+ const result = await reviewApi.request("/api/openapi.json");
1719
+ if (result.status === 401) throw new Error("Unauthorized (401): DataOps session expired; sign in again to read the API reference");
1720
+ if (result.status === 403) throw new Error("Forbidden (403): the DataOps OpenAPI document is not available to this role");
1721
+ if (result.status === 404) throw new Error("DataOps API docs are unavailable: /api/openapi.json is not configured (DATAOPS_OPENAPI_UNAVAILABLE)");
1722
+ if (result.status === 503) throw new Error("DataOps API docs are unavailable: this workspace has no DataOps session");
1723
+ if (result.status < 200 || result.status >= 300) throw new Error("DataOps API docs request failed: HTTP " + result.status.toString());
1724
+ try {
1725
+ return searchOpenApiDocument(result.body, args);
1726
+ } catch (error) {
1727
+ if (error instanceof ApiDocsUnavailableError) throw new Error(error.message);
1728
+ throw error;
1729
+ }
1227
1730
  }
1228
- }
1229
- await approve("review_decide", exec, "Review " + args.decision + " for " + (args.skills ?? []).length.toString() + " skill(s) / " + (args.workflows ?? []).length.toString() + " workflow(s)");
1230
- const value = await call("/api/ai/review/decide", {
1231
- method: "POST",
1232
- body: JSON.stringify({
1233
- skills: args.skills ?? [],
1234
- workflows: args.workflows ?? [],
1235
- decision: args.decision,
1236
- note: args.note ?? "",
1237
- authorization: { confirmed: true }
1238
- })
1239
- });
1240
- audit("review_decide", exec, {
1241
- decision: args.decision,
1242
- skills: args.skills ?? [],
1243
- workflows: args.workflows ?? [],
1244
- confirmed: true
1245
- });
1246
- return value;
1731
+ })
1247
1732
  }
1248
- }));
1249
- register(defineTool({
1250
- name: "review_notifications_list",
1251
- description: "List the caller's review notifications and unread count. Read-only and always scoped to the current DataOps user.",
1252
- parameters: {
1253
- unread_only: {
1254
- type: "boolean",
1255
- description: "Only unread notifications."
1256
- },
1257
- limit: {
1258
- type: "number",
1259
- description: "Maximum rows (default 20)."
1260
- }
1261
- },
1262
- output: {
1263
- schema: open,
1264
- render
1265
- },
1266
- execute: async (args) => {
1267
- await loadScope(reviewApi);
1268
- const query = new URLSearchParams({
1269
- unreadOnly: args.unread_only === true ? "true" : "false",
1270
- limit: String(args.limit ?? 20)
1271
- });
1272
- return await call("/api/ai/review-notifications?" + query.toString());
1733
+ ];
1734
+ }
1735
+ //#endregion
1736
+ //#region src/tool-manager.ts
1737
+ /**
1738
+ * Reconcile the managed tools with the current DataOps scope.
1739
+ *
1740
+ * Construct once per plugin activation. `refresh` is idempotent and coalesces concurrent calls;
1741
+ * the constructor also subscribes to the credential observation so an expired session removes
1742
+ * the tools without waiting for another explicit sync.
1743
+ */
1744
+ var AiToolManager = class {
1745
+ ctx;
1746
+ reviewApi;
1747
+ definitions;
1748
+ registrations = /* @__PURE__ */ new Map();
1749
+ unsubscribeExpiry;
1750
+ disposeViewer;
1751
+ scope;
1752
+ revision = 0;
1753
+ inflight;
1754
+ disposed = false;
1755
+ constructor(ctx, reviewApi) {
1756
+ this.ctx = ctx;
1757
+ this.reviewApi = reviewApi;
1758
+ this.definitions = new Map(buildAiManagementTools(ctx, reviewApi, () => this.scope).map((tool) => [tool.name, tool.definition]));
1759
+ this.unsubscribeExpiry = onAuthStateChange((state) => {
1760
+ if (state === "expired") this.clear();
1761
+ });
1762
+ }
1763
+ /** The scope the current registration set was derived from, when one has been read. */
1764
+ currentScope() {
1765
+ return this.scope;
1766
+ }
1767
+ /** The tool names currently registered, for diagnostics and tests. */
1768
+ registeredToolNames() {
1769
+ return AI_TOOL_NAMES.filter((name) => this.registrations.has(name));
1770
+ }
1771
+ /**
1772
+ * Re-read the workspace's DataOps scope and reconcile the tool list.
1773
+ *
1774
+ * Definitive denials clear the list; transient failures (network, 5xx) keep what the last good
1775
+ * observation produced. The method never throws: sync happens in the background of a browser
1776
+ * request and must not fail that request.
1777
+ * @param force - Start a new read even when one is already in flight. Credential adoption uses
1778
+ * this because an older read may have captured the previous token; its answer must not win
1779
+ * over the scope the new token produces.
1780
+ */
1781
+ async refresh(force = false) {
1782
+ if (this.disposed) return;
1783
+ if (this.inflight !== void 0 && !force) return this.inflight;
1784
+ const revision = ++this.revision;
1785
+ const run = this.loadScope(revision).catch((error) => {
1786
+ this.ctx.logger.warn("dataops-managed: tool disclosure refresh failed");
1787
+ this.ctx.logger.warn(error);
1788
+ }).finally(() => {
1789
+ if (this.inflight === run) this.inflight = void 0;
1790
+ });
1791
+ this.inflight = run;
1792
+ return run;
1793
+ }
1794
+ /**
1795
+ * Apply a context answer already fetched by a same-origin route.
1796
+ *
1797
+ * The review-context route is how the UI learns about a role change, so its answer is also the
1798
+ * freshest scope this plugin will see. Reusing it avoids a second fetch on a page that polls.
1799
+ * @param result - The upstream status and parsed body.
1800
+ */
1801
+ observeContext(result) {
1802
+ if (result.status === 200) {
1803
+ this.applyContext(result.body);
1804
+ return;
1273
1805
  }
1274
- }));
1275
- register(defineTool({
1276
- name: "review_notifications_read",
1277
- description: "Mark the caller's review notifications as read, either selected ids or all. Scoped to the current DataOps user; no secret is involved.",
1278
- parameters: {
1279
- ids: {
1280
- type: "array",
1281
- items: { type: "string" },
1282
- description: "Notification ids to mark read."
1283
- },
1284
- all: {
1285
- type: "boolean",
1286
- description: "Mark every notification read."
1806
+ if (result.status === 401 || result.status === 403 || result.status === 404 || result.status === 503) this.clear();
1807
+ }
1808
+ /**
1809
+ * Apply one parsed context body without contacting DataOps.
1810
+ * @param body - Parsed `/api/ai/review/context` body.
1811
+ */
1812
+ applyContext(body) {
1813
+ if (this.disposed) return;
1814
+ this.revision += 1;
1815
+ const scope = normalizeScope(body);
1816
+ this.applyScope(scope);
1817
+ }
1818
+ /** Drop every managed tool because no usable DataOps scope is known. */
1819
+ clear() {
1820
+ if (this.disposed) return;
1821
+ this.revision += 1;
1822
+ this.applyScope(void 0);
1823
+ }
1824
+ /** Dispose every tool this manager registered and stop observing auth state. */
1825
+ dispose() {
1826
+ if (this.disposed) return;
1827
+ this.disposed = true;
1828
+ this.revision += 1;
1829
+ this.unsubscribeExpiry();
1830
+ this.disposeRegistrations();
1831
+ this.disposeViewer?.();
1832
+ this.disposeViewer = void 0;
1833
+ this.scope = void 0;
1834
+ }
1835
+ async loadScope(revision) {
1836
+ let result;
1837
+ try {
1838
+ result = await this.reviewApi.request("/api/ai/review/context");
1839
+ } catch (error) {
1840
+ this.ctx.logger.warn("dataops-managed: tool disclosure could not read the DataOps scope");
1841
+ this.ctx.logger.warn(error);
1842
+ return;
1843
+ }
1844
+ if (this.disposed || revision !== this.revision) return;
1845
+ if (result.status === 200) {
1846
+ this.applyScope(normalizeScope(result.body));
1847
+ return;
1848
+ }
1849
+ if (result.status === 401 || result.status === 403 || result.status === 404 || result.status === 503) {
1850
+ this.applyScope(void 0);
1851
+ return;
1852
+ }
1853
+ this.ctx.logger.warn("dataops-managed: tool disclosure scope read failed with HTTP " + result.status.toString());
1854
+ }
1855
+ /** Store the scope and make the registry match it. */
1856
+ applyScope(scope) {
1857
+ this.scope = scope;
1858
+ if (this.disposed) return;
1859
+ this.applyViewer(scope);
1860
+ const desired = new Set(scope === void 0 ? [] : AI_TOOL_NAMES.filter((name) => isToolVisible(name, scope)));
1861
+ for (const [name, registration] of this.registrations) {
1862
+ if (desired.has(name)) continue;
1863
+ this.disposeRegistration(name, registration);
1864
+ }
1865
+ for (const name of AI_TOOL_NAMES) {
1866
+ if (!desired.has(name) || this.registrations.has(name)) continue;
1867
+ const definition = this.definitions.get(name);
1868
+ if (definition === void 0) continue;
1869
+ try {
1870
+ const dispose = this.ctx.tools.register(definition);
1871
+ this.registrations.set(name, { dispose });
1872
+ } catch (error) {
1873
+ this.ctx.logger.warn("dataops-managed: could not register tool " + name);
1874
+ this.ctx.logger.warn(error);
1287
1875
  }
1288
- },
1289
- output: {
1290
- schema: open,
1291
- render
1292
- },
1293
- execute: async (args, exec) => {
1294
- await loadScope(reviewApi);
1295
- const value = await call("/api/ai/review-notifications/read", {
1296
- method: "POST",
1297
- body: JSON.stringify({
1298
- ids: args.ids ?? [],
1299
- all: args.all === true
1300
- })
1301
- });
1302
- audit("review_notifications_read", exec, {
1303
- count: args.ids?.length ?? 0,
1304
- all: args.all === true
1305
- });
1306
- return value;
1307
1876
  }
1877
+ }
1878
+ /** Map one DataOps scope to the skill registry's audience filter. */
1879
+ applyViewer(scope) {
1880
+ this.disposeViewer?.();
1881
+ this.disposeViewer = void 0;
1882
+ const viewer = scope === void 0 ? {
1883
+ role: "user",
1884
+ permissions: []
1885
+ } : {
1886
+ role: isSuperAdmin(scope) ? "super-admin" : isAdmin(scope) || isDepartmentAdmin(scope) ? "admin" : "user",
1887
+ permissions: [...scope.permissions ?? scope.permissionKeys ?? []]
1888
+ };
1889
+ try {
1890
+ const skills = this.ctx.skills;
1891
+ if (typeof skills.useViewer !== "function") return;
1892
+ this.disposeViewer = skills.useViewer(viewer);
1893
+ } catch (error) {
1894
+ this.ctx.logger.warn("dataops-managed: could not apply the skill viewer");
1895
+ this.ctx.logger.warn(error);
1896
+ }
1897
+ }
1898
+ /** Dispose one registration, tolerating a disposer that has already run. */
1899
+ disposeRegistration(name, registration) {
1900
+ this.registrations.delete(name);
1901
+ try {
1902
+ registration.dispose();
1903
+ } catch (error) {
1904
+ this.ctx.logger.warn("dataops-managed: could not dispose tool " + name);
1905
+ this.ctx.logger.warn(error);
1906
+ }
1907
+ }
1908
+ /** Dispose the whole current set. */
1909
+ disposeRegistrations() {
1910
+ for (const [name, registration] of [...this.registrations]) this.disposeRegistration(name, registration);
1911
+ }
1912
+ };
1913
+ //#endregion
1914
+ //#region src/connector-state.ts
1915
+ /**
1916
+ * The DataOps connector's workspace-visible state and skill-center catalogue.
1917
+ *
1918
+ * The skill center is skill-first, with connectors as its fourth tab. DataOps contributes one
1919
+ * connector whose capability rows are derived from the same scope that drives tool disclosure;
1920
+ * a capability the account cannot use is omitted rather than rendered as a disabled placeholder.
1921
+ *
1922
+ * This module owns only state and presentation. It never authorizes a tool call or a DataOps
1923
+ * request: the backend remains the final boundary.
1924
+ *
1925
+ * @module connector-state
1926
+ */
1927
+ /** Same-origin route the skill center's connector tab reads and writes. */
1928
+ const CONNECTORS_PATH = "/api/dsh-skill-center/connectors";
1929
+ /** Connector id shown in the skill center. */
1930
+ const DATAOPS_CONNECTOR_ID = "dataops-managed";
1931
+ /** Resolve the persisted state file without depending on a global DSH path. */
1932
+ const CONNECTOR_STATE_PATH = join(process.env.DSH_HOME?.trim() || "/workspace/.dataops/dsh", "dataops-managed", "connector-state.json");
1933
+ /** Capability definitions before permission filtering. */
1934
+ const CAPABILITIES = [
1935
+ {
1936
+ id: "skill.plaza",
1937
+ name: "技能广场",
1938
+ description: "读取、安装和管理当前账号可见的技能",
1939
+ allowed: (scope) => hasPermission(scope, "skill:read")
1940
+ },
1941
+ {
1942
+ id: "review.queue",
1943
+ name: "审核队列",
1944
+ description: "查看待审技能、扫描风险并给出审核结论",
1945
+ allowed: (scope) => canReview(scope)
1946
+ },
1947
+ {
1948
+ id: "data.query",
1949
+ name: "数据查询",
1950
+ description: "通过 DataOps 数据查询能力回答业务问题",
1951
+ allowed: () => true
1952
+ },
1953
+ {
1954
+ id: "api.docs",
1955
+ name: "管理接口文档",
1956
+ description: "仅超级管理员可查询 DataOps OpenAPI 接口文档",
1957
+ allowed: (scope) => isSuperAdmin(scope)
1958
+ }
1959
+ ];
1960
+ /** Read state; a missing or corrupt file is a safe disabled state. */
1961
+ async function readConnectorState(path = CONNECTOR_STATE_PATH) {
1962
+ try {
1963
+ const payload = JSON.parse(await readFile(path, "utf8"));
1964
+ return {
1965
+ enabled: payload.enabled === true,
1966
+ capabilities: typeof payload.capabilities === "object" && payload.capabilities !== null ? { ...payload.capabilities } : {}
1967
+ };
1968
+ } catch {
1969
+ return {
1970
+ enabled: false,
1971
+ capabilities: {}
1972
+ };
1973
+ }
1974
+ }
1975
+ /** Persist state atomically. */
1976
+ async function writeConnectorState(state, path = CONNECTOR_STATE_PATH) {
1977
+ await mkdir(dirname(path), { recursive: true });
1978
+ const temporary = path + ".tmp-" + process.pid.toString();
1979
+ await writeFile(temporary, JSON.stringify(state, null, 2), "utf8");
1980
+ await rename(temporary, path);
1981
+ }
1982
+ /** Build the connector row for one current scope. */
1983
+ function buildConnectorView(scope, state) {
1984
+ const capabilities = scope === void 0 ? [] : CAPABILITIES.filter((capability) => capability.allowed(scope)).map((capability) => ({
1985
+ id: capability.id,
1986
+ name: capability.name,
1987
+ description: capability.description,
1988
+ enabled: state.capabilities[capability.id] !== false,
1989
+ permitted: true
1308
1990
  }));
1309
- return () => {
1310
- for (const dispose of disposers) dispose();
1991
+ return {
1992
+ id: DATAOPS_CONNECTOR_ID,
1993
+ name: "万相AI中台连接器",
1994
+ vendor: "DataOps",
1995
+ description: "把万相AI中台的技能、审核和数据能力接入当前工作区。",
1996
+ enabled: state.enabled,
1997
+ connected: scope !== void 0,
1998
+ capabilities
1311
1999
  };
1312
2000
  }
2001
+ /** Whether one capability id is in the fixed catalogue. */
2002
+ function hasCapability(id) {
2003
+ return CAPABILITIES.some((capability) => capability.id === id);
2004
+ }
1313
2005
  //#endregion
1314
2006
  //#region src/workspace-limits.ts
1315
2007
  /** Narrow a value to a plain object. */
@@ -1874,8 +2566,12 @@ function adoptRequestJwt(ctx, accessRef, request) {
1874
2566
  const token = bearerToken(request);
1875
2567
  if (token === null) return Promise.resolve();
1876
2568
  return (async () => {
1877
- if ((await ctx.credentials.resolve(accessRef))?.value === token) return;
2569
+ if ((await ctx.credentials.resolve(accessRef))?.value === token) {
2570
+ resetAuthState();
2571
+ return;
2572
+ }
1878
2573
  await ctx.credentials.set(accessRef, token);
2574
+ resetAuthState();
1879
2575
  })().catch((error) => {
1880
2576
  ctx.logger.warn("dataops-managed: request JWT could not be adopted");
1881
2577
  ctx.logger.warn(error);
@@ -1964,7 +2660,11 @@ async function apply(ctx, config) {
1964
2660
  * a failed connect is reported rather than thrown.
1965
2661
  */
1966
2662
  const connectExisting = async () => {
1967
- if (await ctx.credentials.resolve(accessRef) === void 0) return;
2663
+ if (await ctx.credentials.resolve(accessRef) === void 0) {
2664
+ aiTools.clear();
2665
+ return;
2666
+ }
2667
+ aiTools.refresh();
1968
2668
  try {
1969
2669
  await ensureMcp();
1970
2670
  } catch (error) {
@@ -2044,7 +2744,21 @@ async function apply(ctx, config) {
2044
2744
  credentialRef: config.credentialRef
2045
2745
  });
2046
2746
  reviewApiRef = reviewApi;
2047
- ctx.effect(() => registerAiManagementTools(ctx, reviewApi), "dataops-managed: skill and review tools");
2747
+ /**
2748
+ * The managed tool list follows the workspace's DataOps scope.
2749
+ *
2750
+ * `AiToolManager` registers only the skill/review tools the current principal can use and
2751
+ * disposes them when the role changes. The credential event below is the existing auth-sync
2752
+ * path: JWT adoption and the managed-auth route both go through `credentials.set`, which
2753
+ * publishes this event after the write commits. The manager also observes the review-context
2754
+ * route's answer, because a role edit does not change the JWT but does change the scope.
2755
+ */
2756
+ const aiTools = new AiToolManager(ctx, reviewApi);
2757
+ ctx.effect(() => () => aiTools.dispose(), "dataops-managed: skill and review tool disclosure");
2758
+ ctx.effect(() => ctx.on("credentials/reference-updated", (ref) => {
2759
+ if (String(ref) !== config.credentialRef) return;
2760
+ aiTools.refresh(true);
2761
+ }), "dataops-managed: tool disclosure follows the DataOps credential");
2048
2762
  setWorkspaceLimits(new WorkspaceLimits(ctx, {
2049
2763
  baseUrl,
2050
2764
  credentialRef: config.credentialRef
@@ -2095,6 +2809,8 @@ async function apply(ctx, config) {
2095
2809
  return;
2096
2810
  }
2097
2811
  await ctx.credentials.set(accessRef, token);
2812
+ resetAuthState();
2813
+ aiTools.refresh(true);
2098
2814
  await ensureMcp();
2099
2815
  await adoptNow();
2100
2816
  response.writeHead(204, { "cache-control": "no-store" });
@@ -2330,6 +3046,7 @@ async function apply(ctx, config) {
2330
3046
  }
2331
3047
  try {
2332
3048
  const result = await api.request("/api/ai/review/context");
3049
+ aiTools.observeContext(result);
2333
3050
  writeJson(response, result.status, result.body);
2334
3051
  } catch (error) {
2335
3052
  ctx.logger.warn("dataops-managed: review context failed");
@@ -2766,9 +3483,75 @@ async function apply(ctx, config) {
2766
3483
  response.end();
2767
3484
  return;
2768
3485
  }
3486
+ await adoptRequestJwt(ctx, accessRef, request);
2769
3487
  writeJson(response, 200, { state: currentAuthState() });
2770
3488
  }
2771
3489
  }), "dataops-managed: auth state route");
3490
+ /**
3491
+ * The skill center's connector tab. The route is a same-origin presentation bridge: reads
3492
+ * return only capabilities the current scope can use, and writes persist the connector's own
3493
+ * enable state. It never substitutes for a DataOps authorization decision.
3494
+ */
3495
+ ctx.effect(() => ctx.webServer.register({
3496
+ kind: "exact",
3497
+ path: CONNECTORS_PATH,
3498
+ handler: async (request, response) => {
3499
+ try {
3500
+ if (request.method === "GET") {
3501
+ if (aiTools.currentScope() === void 0) await aiTools.refresh();
3502
+ const state = await readConnectorState();
3503
+ writeJson(response, 200, { connectors: [buildConnectorView(aiTools.currentScope(), state)] });
3504
+ return;
3505
+ }
3506
+ if (request.method === "POST") {
3507
+ const body = await readJson(request);
3508
+ if (body === null) {
3509
+ writeJson(response, 400, { error: "connector state requires a JSON body" });
3510
+ return;
3511
+ }
3512
+ if (body.connectorId !== "dataops-managed") {
3513
+ writeJson(response, 404, { error: "unknown connector" });
3514
+ return;
3515
+ }
3516
+ const state = await readConnectorState();
3517
+ const capabilityId = typeof body.capabilityId === "string" ? body.capabilityId : void 0;
3518
+ if (capabilityId !== void 0) {
3519
+ if (!hasCapability(capabilityId)) {
3520
+ writeJson(response, 400, { error: "unknown capability" });
3521
+ return;
3522
+ }
3523
+ if (!buildConnectorView(aiTools.currentScope(), state).capabilities.some((row) => row.id === capabilityId)) {
3524
+ writeJson(response, 403, { error: "capability is not available to this account" });
3525
+ return;
3526
+ }
3527
+ if (typeof body.enabled !== "boolean") {
3528
+ writeJson(response, 400, { error: "capability state requires enabled:boolean" });
3529
+ return;
3530
+ }
3531
+ state.capabilities[capabilityId] = body.enabled;
3532
+ } else {
3533
+ if (typeof body.enabled !== "boolean") {
3534
+ writeJson(response, 400, { error: "connector state requires enabled:boolean" });
3535
+ return;
3536
+ }
3537
+ state.enabled = body.enabled;
3538
+ }
3539
+ await writeConnectorState(state);
3540
+ writeJson(response, 200, { connectors: [buildConnectorView(aiTools.currentScope(), state)] });
3541
+ return;
3542
+ }
3543
+ response.writeHead(405, {
3544
+ allow: "GET, POST",
3545
+ "content-length": "0"
3546
+ });
3547
+ response.end();
3548
+ } catch (error) {
3549
+ ctx.logger.warn("dataops-managed: connector catalogue route failed");
3550
+ ctx.logger.warn(error);
3551
+ writeJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
3552
+ }
3553
+ }
3554
+ }), "dataops-managed: skill-center connector catalogue");
2772
3555
  await connectExisting();
2773
3556
  }
2774
3557
  /** Read and parse a JSON request body, tolerating an absent one. */