@capybara-hq/kernel 0.1.0-rc.26 → 0.1.0-rc.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/app.js +2 -0
  2. package/dist/catalog/catalog-content.d.ts +18 -1
  3. package/dist/catalog/catalog-content.js +5 -2
  4. package/dist/catalog/catalog-scope.service.js +3 -2
  5. package/dist/catalog/marketplaces.service.d.ts +1 -1
  6. package/dist/catalog/org-catalog.service.js +13 -3
  7. package/dist/catalog/plugin-install.d.ts +37 -16
  8. package/dist/catalog/plugin-install.js +108 -34
  9. package/dist/catalog/plugin-upgrade.js +1 -0
  10. package/dist/channels/actions.d.ts +4 -1
  11. package/dist/channels/actions.js +5 -2
  12. package/dist/channels/cards.d.ts +7 -1
  13. package/dist/channels/cards.js +28 -1
  14. package/dist/channels/phrases.d.ts +15 -0
  15. package/dist/channels/phrases.js +9 -0
  16. package/dist/config/schema.d.ts +8 -0
  17. package/dist/config/schema.js +69 -0
  18. package/dist/conversations/events.d.ts +14 -0
  19. package/dist/conversations/events.js +20 -0
  20. package/dist/db/kernel-schema.d.ts +19 -4
  21. package/dist/errors.d.ts +5 -0
  22. package/dist/errors.js +5 -0
  23. package/dist/git/gateway.d.ts +93 -0
  24. package/dist/git/gateway.js +750 -0
  25. package/dist/git/gateway.route.d.ts +16 -0
  26. package/dist/git/gateway.route.js +41 -0
  27. package/dist/git/git.module.d.ts +10 -0
  28. package/dist/git/git.module.js +31 -0
  29. package/dist/git/identity.d.ts +80 -0
  30. package/dist/git/identity.js +209 -0
  31. package/dist/git/pkt-line.d.ts +53 -0
  32. package/dist/git/pkt-line.js +146 -0
  33. package/dist/git/run-end.d.ts +29 -0
  34. package/dist/git/run-end.js +121 -0
  35. package/dist/git/sources.d.ts +42 -0
  36. package/dist/git/sources.js +134 -0
  37. package/dist/git/tickets.d.ts +69 -0
  38. package/dist/git/tickets.js +194 -0
  39. package/dist/http/api.module.js +3 -0
  40. package/dist/http/app.d.ts +6 -0
  41. package/dist/http/app.js +3 -0
  42. package/dist/infra/config/kernel-config.service.js +2 -0
  43. package/dist/orgs/settings.service.d.ts +21 -0
  44. package/dist/orgs/settings.service.js +18 -0
  45. package/dist/overrides.d.ts +6 -0
  46. package/dist/overrides.js +4 -0
  47. package/dist/platform/exclusions.js +3 -0
  48. package/dist/runtime/run-finish.d.ts +5 -0
  49. package/dist/runtime/run-finish.js +12 -3
  50. package/dist/runtime/runtime.module.js +2 -0
  51. package/dist/runtime/waits.js +7 -5
  52. package/dist/schedules/fire.d.ts +3 -3
  53. package/dist/schedules/fire.js +7 -4
  54. package/dist/schedules/grant-rules.d.ts +40 -0
  55. package/dist/schedules/grant-rules.js +83 -0
  56. package/dist/schedules/scheduled-tasks.service.d.ts +9 -7
  57. package/dist/schedules/scheduled-tasks.service.js +50 -15
  58. package/dist/schedules/types.d.ts +2 -0
  59. package/dist/tools/approvals.service.d.ts +100 -19
  60. package/dist/tools/approvals.service.js +288 -21
  61. package/dist/tools/builtin-names.d.ts +4 -2
  62. package/dist/tools/builtin-names.js +6 -1
  63. package/dist/tools/builtins/schedules.d.ts +11 -9
  64. package/dist/tools/builtins/schedules.js +202 -44
  65. package/dist/tools/connector-account-policies.d.ts +8 -0
  66. package/dist/tools/connector-account-policies.js +8 -0
  67. package/dist/tools/connectors.controller.d.ts +2 -0
  68. package/dist/tools/connectors.controller.js +5 -1
  69. package/dist/tools/connectors.service.d.ts +46 -3
  70. package/dist/tools/connectors.service.js +98 -9
  71. package/dist/tools/credentials.d.ts +18 -0
  72. package/dist/tools/credentials.js +60 -0
  73. package/dist/tools/credentials.module.d.ts +7 -0
  74. package/dist/tools/credentials.module.js +22 -0
  75. package/dist/tools/git-asks.d.ts +113 -0
  76. package/dist/tools/git-asks.js +174 -0
  77. package/dist/tools/git-push-policy.d.ts +89 -0
  78. package/dist/tools/git-push-policy.js +175 -0
  79. package/dist/tools/info.d.ts +3 -3
  80. package/dist/tools/info.js +3 -3
  81. package/dist/tools/network-asks.d.ts +5 -0
  82. package/dist/tools/network-asks.js +9 -2
  83. package/dist/tools/tools.module.js +4 -3
  84. package/package.json +3 -3
  85. package/src/migrations/0126_git_gateway.sql +10 -0
  86. package/src/migrations/0127_git_push_policy.sql +12 -0
  87. package/src/migrations/0128_scheduled_task_plugin_disabled.sql +10 -0
  88. package/src/migrations/0129_connector_plugin_disabled.sql +5 -0
@@ -96,7 +96,10 @@ let ConnectorsService = class ConnectorsService {
96
96
  txHost;
97
97
  guides;
98
98
  constructor(kdb, registry,
99
- /** Extension manifests (a slug may not take an extension's name). */
99
+ /**
100
+ * Extension manifests: a derived default slug avoids an extension's name (an explicit slug may
101
+ * take one; connector and extension tool keys live in separate namespaces).
102
+ */
100
103
  extensions, credentials, config, drift,
101
104
  /** The org row lock (orgs/org-lock.ts): joins the caller's transaction. */
102
105
  orgLock, txHost,
@@ -287,10 +290,6 @@ let ConnectorsService = class ConnectorsService {
287
290
  checkCreateInput(actorAccountId, input) {
288
291
  const slug = this.resolveSlug(input);
289
292
  const endpoint = this.parseEndpoint(input.connector, input.endpoint);
290
- // a defaulted slug skips plugin names itself (`freeName`)
291
- if (!input.slugDefault && this.extensions.manifests().some((m) => m.name === slug)) {
292
- throw fail("slug", `Slug "${slug}" is the name of an installed plugin; choose another`);
293
- }
294
293
  const auth = input.auth;
295
294
  const credential = input.credential ?? undefined;
296
295
  if (!isOAuth(auth)) {
@@ -441,6 +440,7 @@ let ConnectorsService = class ConnectorsService {
441
440
  connector: input.connector,
442
441
  endpoint: JSON.stringify(endpoint),
443
442
  auth: JSON.stringify(auth),
443
+ git_hosts: input.git !== undefined ? JSON.stringify(input.git.hosts) : null,
444
444
  created_by: actorAccountId,
445
445
  catalog_item_id: input.template?.itemId ?? null,
446
446
  catalog_version: input.template?.version ?? null,
@@ -527,14 +527,21 @@ let ConnectorsService = class ConnectorsService {
527
527
  */
528
528
  async applyTemplateIn(orgId, connectorId, actorAccountId, p, actorTokenId) {
529
529
  const t = this.db;
530
- await this.row(t, orgId, connectorId, true);
530
+ const before = await this.row(t, orgId, connectorId, true);
531
531
  const { input } = p;
532
532
  const policies = input.template.defaultToolPolicies;
533
+ const botCapable = input.git !== undefined &&
534
+ input.git.hosts.length > 0 &&
535
+ input.auth.type !== "none" &&
536
+ input.auth.ownership === "org";
533
537
  await t
534
538
  .updateTable("connectors")
535
539
  .set({
536
540
  endpoint: JSON.stringify(p.endpoint),
537
541
  auth: JSON.stringify(input.auth),
542
+ git_hosts: input.git !== undefined ? JSON.stringify(input.git.hosts) : null,
543
+ // "use for git" needs org ownership and git hosts: a template that drops either clears it
544
+ ...(!botCapable && { use_for_git: false }),
538
545
  catalog_item_id: input.template.itemId,
539
546
  catalog_version: input.template.version,
540
547
  template_tool_policies: policies ? JSON.stringify(policies) : null,
@@ -603,6 +610,7 @@ let ConnectorsService = class ConnectorsService {
603
610
  credentialsReset: input.resetCredentials,
604
611
  credentialProvided: p.secret !== undefined || p.oauth !== undefined,
605
612
  policies: policyChanges,
613
+ ...(before.use_for_git && !botCapable && { useForGit: { from: true, to: false } }),
606
614
  },
607
615
  });
608
616
  }
@@ -774,6 +782,8 @@ let ConnectorsService = class ConnectorsService {
774
782
  status: r.status,
775
783
  platformTemplateId: r.platform_template_id ?? null,
776
784
  catalogItemId: r.catalog_item_id ?? null,
785
+ git: r.git_hosts !== null && r.git_hosts.length > 0 ? { hosts: r.git_hosts } : null,
786
+ useForGit: r.use_for_git,
777
787
  createdBy: r.created_by,
778
788
  createdAt: r.created_at,
779
789
  updatedAt: r.updated_at,
@@ -987,15 +997,34 @@ let ConnectorsService = class ConnectorsService {
987
997
  problems.push({ path, message: issue });
988
998
  }
989
999
  }
1000
+ const appAuth = app.auth;
1001
+ if (patch.useForGit === true) {
1002
+ if (appAuth.type === "none" || appAuth.ownership !== "org") {
1003
+ problems.push({
1004
+ path: "useForGit",
1005
+ message: "Only an org-credential connector can be used for git; members' own connections always are",
1006
+ });
1007
+ }
1008
+ else if (app.git_hosts === null || app.git_hosts.length === 0) {
1009
+ problems.push({ path: "useForGit", message: "This connector declares no git hosts" });
1010
+ }
1011
+ }
990
1012
  if (problems.length > 0) {
991
1013
  throw new ApiError("validation_failed", "Request validation failed", problems);
992
1014
  }
993
- if (patch.name !== undefined || patch.status !== undefined) {
1015
+ const gitChanged = patch.useForGit !== undefined && patch.useForGit !== app.use_for_git;
1016
+ if (patch.name !== undefined || patch.status !== undefined || gitChanged) {
994
1017
  await t
995
1018
  .updateTable("connectors")
996
1019
  .set({
997
1020
  ...(patch.name !== undefined && { name: patch.name }),
998
- ...(patch.status !== undefined && { status: patch.status }),
1021
+ // An explicit status change is the org's own decision: a plugin switch-off no longer
1022
+ // owns this connector, so a later switch-on must not turn it back on
1023
+ ...(patch.status !== undefined && {
1024
+ status: patch.status,
1025
+ disabled_by_plugin_id: null,
1026
+ }),
1027
+ ...(gitChanged && { use_for_git: patch.useForGit === true }),
999
1028
  updated_at: sql `now()`,
1000
1029
  })
1001
1030
  .where("id", "=", connectorId)
@@ -1045,6 +1074,7 @@ let ConnectorsService = class ConnectorsService {
1045
1074
  detail: {
1046
1075
  ...(patch.name !== undefined && { name: { from: app.name, to: patch.name } }),
1047
1076
  ...(patch.status !== undefined && { status: { from: app.status, to: patch.status } }),
1077
+ ...(gitChanged && { useForGit: { from: app.use_for_git, to: patch.useForGit } }),
1048
1078
  ...(toolChanges.length > 0 && { tools: Object.fromEntries(toolChanges) }),
1049
1079
  ...(allowed.length > 0 && { allowedUnannotated: allowed }),
1050
1080
  },
@@ -1068,6 +1098,57 @@ let ConnectorsService = class ConnectorsService {
1068
1098
  throw notFound();
1069
1099
  return r.org_id;
1070
1100
  }
1101
+ /**
1102
+ * Disables a connector because its plugin was switched off in the org (spec §5 rule 8): its
1103
+ * endpoint and credentials stay. Only a connector that was active is marked with the plugin
1104
+ * (`connectors.disabled_by_plugin_id`), so switching the plugin back on re-enables exactly what
1105
+ * this call disabled; one an org admin disabled by hand keeps no marker and stays disabled. The
1106
+ * caller holds the org row lock and runs in its transaction.
1107
+ */
1108
+ async disableWithPluginIn(orgId, connectorId, itemId) {
1109
+ const t = this.db;
1110
+ const row = await t
1111
+ .selectFrom("connectors")
1112
+ .select(["status"])
1113
+ .where("id", "=", connectorId)
1114
+ .where("org_id", "=", orgId)
1115
+ .where("deleted_at", "is", null)
1116
+ .forUpdate()
1117
+ .executeTakeFirst();
1118
+ if (!row)
1119
+ return;
1120
+ await t
1121
+ .updateTable("connectors")
1122
+ .set({
1123
+ status: "disabled",
1124
+ // An admin's disable is not the plugin's: it is not marked and so stays off on switch-on
1125
+ ...(row.status === "active" && { disabled_by_plugin_id: itemId }),
1126
+ updated_at: sql `now()`,
1127
+ })
1128
+ .where("id", "=", connectorId)
1129
+ .where("org_id", "=", orgId)
1130
+ .where("deleted_at", "is", null)
1131
+ .execute();
1132
+ }
1133
+ /**
1134
+ * Re-enables a connector the given plugin's switch-off disabled
1135
+ * ({@link disableWithPluginIn}) when the plugin is switched back on and its install takes the
1136
+ * connector back through the reuse path. A connector an org admin disabled by hand (no marker)
1137
+ * or one another state left disabled stays disabled. Returns whether it switched it on.
1138
+ */
1139
+ async enableIfPluginDisabledIn(orgId, connectorId, itemId) {
1140
+ const r = await this.db
1141
+ .updateTable("connectors")
1142
+ .set({ status: "active", disabled_by_plugin_id: null, updated_at: sql `now()` })
1143
+ .where("id", "=", connectorId)
1144
+ .where("org_id", "=", orgId)
1145
+ .where("deleted_at", "is", null)
1146
+ .where("status", "=", "disabled")
1147
+ .where("disabled_by_plugin_id", "=", itemId)
1148
+ .returning("id")
1149
+ .executeTakeFirst();
1150
+ return r !== undefined;
1151
+ }
1071
1152
  /**
1072
1153
  * Disables or enables a connector on the platform's behalf (a super admin who need not be a
1073
1154
  * member of the org; the caller checks the super admin): org row lock, the status update, audit
@@ -1081,7 +1162,9 @@ let ConnectorsService = class ConnectorsService {
1081
1162
  const app = await this.row(t, orgId, connectorId, true);
1082
1163
  await t
1083
1164
  .updateTable("connectors")
1084
- .set({ status, updated_at: sql `now()` })
1165
+ // The platform's explicit choice replaces a plugin switch-off's: a later plugin switch-on
1166
+ // leaves the connector as the platform set it
1167
+ .set({ status, disabled_by_plugin_id: null, updated_at: sql `now()` })
1085
1168
  .where("id", "=", connectorId)
1086
1169
  .execute();
1087
1170
  await audit(t, {
@@ -1388,6 +1471,12 @@ __decorate([
1388
1471
  __decorate([
1389
1472
  Transactional()
1390
1473
  ], ConnectorsService.prototype, "update", null);
1474
+ __decorate([
1475
+ Transactional({ propagation: "mandatory" })
1476
+ ], ConnectorsService.prototype, "disableWithPluginIn", null);
1477
+ __decorate([
1478
+ Transactional()
1479
+ ], ConnectorsService.prototype, "enableIfPluginDisabledIn", null);
1391
1480
  __decorate([
1392
1481
  Transactional()
1393
1482
  ], ConnectorsService.prototype, "setStatusAsPlatform", null);
@@ -148,6 +148,24 @@ export declare class Credentials {
148
148
  * 返回新 token;refresh 被拒 → 凭据删除并抛 CredentialMissing。静态凭据无事可做。
149
149
  */
150
150
  onUnauthorized(orgId: string, connectorId: string, auth: ConnectorAuth, accountId: string, stale?: AuthInjection): Promise<void>;
151
+ /**
152
+ * The `Authorization` value one git gateway request presents upstream (sandbox git credentials
153
+ * spec §5, §6.1): `Basic base64(<username>:<token>)` with the connector's token for the actor,
154
+ * built here so the raw token never reaches a caller. `username` is the git host's basic user
155
+ * from the connector's `git_hosts` (GitHub: `x-access-token`). An OAuth token is refreshed as
156
+ * {@link injection} does (cross-replica lock); a `basic` connector presents its stored pair as
157
+ * it is. The header, its base64 and decoded forms are registered for log redaction. Missing (or
158
+ * a refresh refused) -> {@link CredentialMissing}; `none` / `headers` auth cannot authenticate
159
+ * git (git sources exclude them) and throw.
160
+ */
161
+ gitAuthorization(orgId: string, connectorId: string, auth: ConnectorAuth, accountId: string, username: string): Promise<string>;
162
+ /**
163
+ * The git host answered 401 to `stale` (a value {@link gitAuthorization} returned): an OAuth
164
+ * connector force-refreshes in the refresh lock, unless another request already replaced that
165
+ * token, so the next {@link gitAuthorization} presents a new one; a refused refresh deletes the
166
+ * credential and throws {@link CredentialMissing}. Static credentials: nothing to do.
167
+ */
168
+ gitUnauthorized(orgId: string, connectorId: string, auth: ConnectorAuth, accountId: string, stale: string): Promise<void>;
151
169
  /** 「需要重新连接」→ CredentialMissing(个人凭据由管线转成 connection_required)。 */
152
170
  private oauthCall;
153
171
  /**
@@ -382,6 +382,66 @@ let Credentials = class Credentials {
382
382
  const token = header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined;
383
383
  await this.oauthCall(connectorId, auth, () => this.oauth().forceRefresh(orgId, connectorId, auth, ownerOf(auth, accountId), token));
384
384
  }
385
+ /**
386
+ * The `Authorization` value one git gateway request presents upstream (sandbox git credentials
387
+ * spec §5, §6.1): `Basic base64(<username>:<token>)` with the connector's token for the actor,
388
+ * built here so the raw token never reaches a caller. `username` is the git host's basic user
389
+ * from the connector's `git_hosts` (GitHub: `x-access-token`). An OAuth token is refreshed as
390
+ * {@link injection} does (cross-replica lock); a `basic` connector presents its stored pair as
391
+ * it is. The header, its base64 and decoded forms are registered for log redaction. Missing (or
392
+ * a refresh refused) -> {@link CredentialMissing}; `none` / `headers` auth cannot authenticate
393
+ * git (git sources exclude them) and throw.
394
+ */
395
+ async gitAuthorization(orgId, connectorId, auth, accountId, username) {
396
+ if (auth.type === "none" || auth.type === "headers") {
397
+ throw new Error(`credentials: auth type ${auth.type} cannot authenticate git`);
398
+ }
399
+ const owner = ownerOf(auth, accountId);
400
+ let user = username;
401
+ let password;
402
+ if (isOAuth(auth)) {
403
+ password = await this.oauthCall(connectorId, auth, () => this.oauth().accessToken(orgId, connectorId, auth, owner));
404
+ }
405
+ else {
406
+ const missing = () => new CredentialMissing(connectorId, auth.ownership);
407
+ const value = (await this.readJson(orgId, keyOf(connectorId, owner), connectorId));
408
+ if (value === null || typeof value !== "object")
409
+ throw missing();
410
+ if (auth.type === "basic") {
411
+ if (typeof value.username !== "string" || typeof value.password !== "string") {
412
+ throw missing();
413
+ }
414
+ user = value.username;
415
+ password = value.password;
416
+ }
417
+ else {
418
+ if (typeof value.token !== "string" || value.token === "")
419
+ throw missing();
420
+ password = value.token;
421
+ }
422
+ }
423
+ const header = `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`;
424
+ this.log.addOrgSecrets(injectionSecrets({ headers: { Authorization: header } }));
425
+ return header;
426
+ }
427
+ /**
428
+ * The git host answered 401 to `stale` (a value {@link gitAuthorization} returned): an OAuth
429
+ * connector force-refreshes in the refresh lock, unless another request already replaced that
430
+ * token, so the next {@link gitAuthorization} presents a new one; a refused refresh deletes the
431
+ * credential and throws {@link CredentialMissing}. Static credentials: nothing to do.
432
+ */
433
+ async gitUnauthorized(orgId, connectorId, auth, accountId, stale) {
434
+ if (!isOAuth(auth))
435
+ return;
436
+ let token;
437
+ if (stale.startsWith("Basic ")) {
438
+ const decoded = Buffer.from(stale.slice("Basic ".length), "base64").toString("utf8");
439
+ const colon = decoded.indexOf(":");
440
+ if (colon >= 0)
441
+ token = decoded.slice(colon + 1);
442
+ }
443
+ await this.oauthCall(connectorId, auth, () => this.oauth().forceRefresh(orgId, connectorId, auth, ownerOf(auth, accountId), token));
444
+ }
385
445
  /** 「需要重新连接」→ CredentialMissing(个人凭据由管线转成 connection_required)。 */
386
446
  async oauthCall(connectorId, auth, work) {
387
447
  try {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Connector credentials ({@link Credentials}) on their own: they need only the global infra
3
+ * (database, keyring, logger, config, locks, Redis), so the git gateway (`GitModule`) can use them
4
+ * without importing `ToolsModule` (whose tool code imports `GitModule`). `ToolsModule` re-exports it.
5
+ */
6
+ export declare class CredentialsModule {
7
+ }
@@ -0,0 +1,22 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { Module } from "@nestjs/common";
8
+ import { Credentials } from "./credentials.js";
9
+ /**
10
+ * Connector credentials ({@link Credentials}) on their own: they need only the global infra
11
+ * (database, keyring, logger, config, locks, Redis), so the git gateway (`GitModule`) can use them
12
+ * without importing `ToolsModule` (whose tool code imports `GitModule`). `ToolsModule` re-exports it.
13
+ */
14
+ let CredentialsModule = class CredentialsModule {
15
+ };
16
+ CredentialsModule = __decorate([
17
+ Module({
18
+ providers: [Credentials],
19
+ exports: [Credentials],
20
+ })
21
+ ], CredentialsModule);
22
+ export { CredentialsModule };
@@ -0,0 +1,113 @@
1
+ import type { Redis } from "ioredis";
2
+ import { type ApprovalRecord } from "./approvals.service.js";
3
+ /**
4
+ * Git push asks in the tool pipeline (sandbox git credentials spec §5.6, §7; the flow of network
5
+ * ask-to-allow spec §6.2–§6.8): the record the gateway leaves when it holds a push, what that
6
+ * record turns into after the command (an ask to the actor, or notes written at once) and the
7
+ * model-visible notes. Pure apart from the Redis record helpers, which the gateway (writes) and
8
+ * the `Bash` pipeline (reads) share.
9
+ */
10
+ /** How long the gateway's record of a held push waits for the command's result (seconds). */
11
+ export declare const GIT_ASK_RECORD_TTL_SECONDS: number;
12
+ /** The Redis key of the push the gateway held during one exec (`execId`: the tool call id). */
13
+ export declare const gitAskKey: (execId: string) => string;
14
+ /** The most git asks one run makes; later held pushes get {@link gitTooManyNote}. */
15
+ export declare const MAX_GIT_ASKS_PER_RUN = 5;
16
+ /** The line a kept result ends with when it had to be clipped to 16 KB. */
17
+ export declare const GIT_ASK_CLIPPED_NOTE = "[Output clipped to its first 16 KB while waiting for approval of the push.]";
18
+ /**
19
+ * What the gateway refused during an exec (Redis `git-ask:<execId>`, TTL
20
+ * {@link GIT_ASK_RECORD_TTL_SECONDS}): the host, the repository (`<owner>/<repo>`, normalized),
21
+ * the refs the push would have updated and why it refused: `ask` (the policy asks the actor;
22
+ * absent means `ask`), `unattended` (a scheduled run without a task grant, `fail` mode) or
23
+ * `denied` (the org's or the member's policy denies it). `connectorId`: the git source whose
24
+ * credential the push used (the ask's connector).
25
+ */
26
+ export interface GitAskRecord {
27
+ host: string;
28
+ repo: string;
29
+ refs: string[];
30
+ connectorId?: string;
31
+ outcome?: "ask" | "unattended" | "denied";
32
+ }
33
+ /** The Redis commands the record helpers use. */
34
+ export type GitAskRedis = Pick<Redis, "set" | "getdel">;
35
+ /**
36
+ * Records the push the gateway held during `execId` (one per exec: a later one replaces it), with
37
+ * TTL {@link GIT_ASK_RECORD_TTL_SECONDS}. An unusable record (no `<owner>/<repo>`) is not written.
38
+ */
39
+ export declare function recordGitAsk(redis: GitAskRedis, execId: string, ask: GitAskRecord): Promise<void>;
40
+ /** Reads and deletes the push held during `execId` (null: none, expired or unreadable). */
41
+ export declare function takeGitAsk(redis: GitAskRedis, execId: string): Promise<GitAskRecord | null>;
42
+ /**
43
+ * Asked: the line the gateway's refusal carries (git prints it in the command's output, so the
44
+ * model reads it in the kept result while the ask waits).
45
+ */
46
+ export declare const gitAskedNote: (repo: string) => string;
47
+ /** Asked, allowed for the rest of the run (a run grant). */
48
+ export declare const gitAllowedOnceNote: (repo: string, refs: readonly string[]) => string;
49
+ /** Asked, allowed from now on (the member's standing allow for the repository). */
50
+ export declare const gitAllowedAlwaysNote: (repo: string, refs: readonly string[]) => string;
51
+ /** Asked and declined (also a repository refused earlier in the run). */
52
+ export declare const gitDeniedNote: (repo: string, refs: readonly string[], reason?: string | null) => string;
53
+ /** Asked, no answer within the approval timeout. */
54
+ export declare const gitExpiredNote: (repo: string, refs: readonly string[], hours: number) => string;
55
+ /** Asked, the person sent a message instead. */
56
+ export declare const gitSkippedNote: (repo: string, refs: readonly string[]) => string;
57
+ /** Asked, the run was stopped before the answer (the note abort writes too). */
58
+ export declare const gitCancelledNote: () => string;
59
+ /** Not asked: another git push ask of the run tree is waiting (at most one at a time). */
60
+ export declare const gitOtherAskPendingNote: (repo: string, refs: readonly string[]) => string;
61
+ /** Not asked: the run made {@link MAX_GIT_ASKS_PER_RUN} asks already. */
62
+ export declare const gitTooManyNote: (repo: string, refs: readonly string[]) => string;
63
+ /** An unattended run in `fail` mode: no ask, the creator can add a task grant. */
64
+ export declare const gitUnattendedNote: (repo: string, refs: readonly string[]) => string;
65
+ /** The org's or the member's policy denies pushes to the repository. */
66
+ export declare const gitPolicyDeniedNote: (repo: string, refs: readonly string[]) => string;
67
+ /** The push had no credential: the actor has no connection for the host (spec §6.2). */
68
+ export declare const gitNotConnectedNote: (host: string) => string;
69
+ /** How the run state keys a repository on a host: `<host>/<owner>/<repo>` (lower-case). */
70
+ export declare const gitRepoKey: (host: string, repo: string) => string;
71
+ /**
72
+ * What the run tree's git asks add up to (the network asks' rules, §6.8 there); repositories are
73
+ * keyed by {@link gitRepoKey}, so a decision about one host says nothing about another.
74
+ */
75
+ export interface GitRunState {
76
+ /** Repositories allowed for the rest of the run tree (「这次允许」). */
77
+ granted: Set<string>;
78
+ /** Repositories refused in the run tree (denied, expired, skipped, cancelled): never asked again. */
79
+ refused: Set<string>;
80
+ /** Git asks of this run (the per-run cap counts them). */
81
+ asks: number;
82
+ /** A git ask of the run tree is still pending (at most one at a time). */
83
+ pending: boolean;
84
+ }
85
+ /** What to do with a push the gateway held. */
86
+ export interface GitPlan {
87
+ /** Ask the actor about the push (the record's facts become the ask's detail). */
88
+ ask: boolean;
89
+ /** Notes written now, after the command output. */
90
+ notes: string[];
91
+ /** `missing_grants` keys of an unattended run (`git:push:<owner>/<repo>`). */
92
+ missingGrants: string[];
93
+ }
94
+ /**
95
+ * Decides what a held push becomes after its command (the caller asks only for a foreground
96
+ * `Bash` call): the gateway's own refusals (`unattended`: its note and a missing grant;
97
+ * `denied`: the policy note) are written at once; an ask (`ask`) about a repository refused
98
+ * earlier in the run tree gets the denied note, one while another git ask of the tree is pending
99
+ * or after {@link MAX_GIT_ASKS_PER_RUN} asks of the run gets its note; the rest is asked.
100
+ */
101
+ export declare function planGitAsk(record: GitAskRecord, run: GitRunState | null): GitPlan;
102
+ /**
103
+ * The note of a settled git ask: one line per outcome; `hours` is the ask's own wait (created to
104
+ * expiry). Null while it is pending or for an approval of another kind.
105
+ */
106
+ export declare function settledGitNotes(a: ApprovalRecord, hours: number): string[] | null;
107
+ /** `text` followed by `notes` after a blank line (unchanged without notes). */
108
+ export declare const withGitNotes: (text: string, notes: readonly string[]) => string;
109
+ /**
110
+ * A kept result's content within 16 KB (UTF-8): the head, then {@link GIT_ASK_CLIPPED_NOTE}. The
111
+ * caller archives large output first, so this rarely cuts.
112
+ */
113
+ export declare const clipGitKept: (content: string) => string;
@@ -0,0 +1,174 @@
1
+ import { GIT_ASK_MAX_REFS, GIT_REF_MAX_CHARS } from "../conversations/events.js";
2
+ import { gitPushGrantKey, normalizeGitRepo } from "../schedules/grant-rules.js";
3
+ import { GIT_ASK_CANCELLED_NOTE } from "./approvals.service.js";
4
+ import { clipKeptWith } from "./network-asks.js";
5
+ /**
6
+ * Git push asks in the tool pipeline (sandbox git credentials spec §5.6, §7; the flow of network
7
+ * ask-to-allow spec §6.2–§6.8): the record the gateway leaves when it holds a push, what that
8
+ * record turns into after the command (an ask to the actor, or notes written at once) and the
9
+ * model-visible notes. Pure apart from the Redis record helpers, which the gateway (writes) and
10
+ * the `Bash` pipeline (reads) share.
11
+ */
12
+ /** How long the gateway's record of a held push waits for the command's result (seconds). */
13
+ export const GIT_ASK_RECORD_TTL_SECONDS = 60 * 60;
14
+ /** The Redis key of the push the gateway held during one exec (`execId`: the tool call id). */
15
+ export const gitAskKey = (execId) => `git-ask:${execId}`;
16
+ /** The most git asks one run makes; later held pushes get {@link gitTooManyNote}. */
17
+ export const MAX_GIT_ASKS_PER_RUN = 5;
18
+ /** The line a kept result ends with when it had to be clipped to 16 KB. */
19
+ export const GIT_ASK_CLIPPED_NOTE = "[Output clipped to its first 16 KB while waiting for approval of the push.]";
20
+ /** A record as stored and read back: valid fields only, refs capped; null when unusable. */
21
+ function cleanRecord(raw) {
22
+ const r = raw;
23
+ if (r === null || typeof r !== "object")
24
+ return null;
25
+ if (typeof r.host !== "string" || r.host === "" || r.host.length > 255)
26
+ return null;
27
+ if (typeof r.repo !== "string")
28
+ return null;
29
+ const repo = normalizeGitRepo(r.repo);
30
+ if (repo === null)
31
+ return null;
32
+ const refs = Array.isArray(r.refs)
33
+ ? [
34
+ ...new Set(r.refs.filter((x) => typeof x === "string" && x !== "" && x.length <= GIT_REF_MAX_CHARS)),
35
+ ].slice(0, GIT_ASK_MAX_REFS)
36
+ : [];
37
+ const outcome = r.outcome === "unattended" || r.outcome === "denied" || r.outcome === "ask"
38
+ ? r.outcome
39
+ : undefined;
40
+ return {
41
+ host: r.host.toLowerCase(),
42
+ repo,
43
+ refs,
44
+ ...(typeof r.connectorId === "string" && { connectorId: r.connectorId }),
45
+ ...(outcome !== undefined && { outcome }),
46
+ };
47
+ }
48
+ /**
49
+ * Records the push the gateway held during `execId` (one per exec: a later one replaces it), with
50
+ * TTL {@link GIT_ASK_RECORD_TTL_SECONDS}. An unusable record (no `<owner>/<repo>`) is not written.
51
+ */
52
+ export async function recordGitAsk(redis, execId, ask) {
53
+ const clean = cleanRecord(ask);
54
+ if (clean === null)
55
+ return;
56
+ await redis.set(gitAskKey(execId), JSON.stringify(clean), "EX", GIT_ASK_RECORD_TTL_SECONDS);
57
+ }
58
+ /** Reads and deletes the push held during `execId` (null: none, expired or unreadable). */
59
+ export async function takeGitAsk(redis, execId) {
60
+ const raw = await redis.getdel(gitAskKey(execId));
61
+ if (raw === null)
62
+ return null;
63
+ try {
64
+ return cleanRecord(JSON.parse(raw));
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /** A ref as notes and cards show it (`refs/heads/` dropped). */
71
+ const shortRef = (ref) => ref.replace(/^refs\/heads\//, "");
72
+ /** "acme/app (main, feat/x)": the repository and up to three refs. */
73
+ function target(repo, refs) {
74
+ if (refs.length === 0)
75
+ return repo;
76
+ const shown = refs.slice(0, 3).map(shortRef).join(", ");
77
+ return `${repo} (${refs.length > 3 ? `${shown} and ${refs.length - 3} more` : shown})`;
78
+ }
79
+ /** "[git] The push to acme/app (main) was held for the user's approval." (the lead of ask notes). */
80
+ const heldLead = (repo, refs) => `[git] The push to ${target(repo, refs)} was held for the user's approval.`;
81
+ /**
82
+ * Asked: the line the gateway's refusal carries (git prints it in the command's output, so the
83
+ * model reads it in the kept result while the ask waits).
84
+ */
85
+ export const gitAskedNote = (repo) => `[git] Push to ${repo} needs approval; the user is asked after this command.`;
86
+ /** Asked, allowed for the rest of the run (a run grant). */
87
+ export const gitAllowedOnceNote = (repo, refs) => `${heldLead(repo, refs)} The user allowed pushes to ${repo} for the rest of this task. Run the push again.`;
88
+ /** Asked, allowed from now on (the member's standing allow for the repository). */
89
+ export const gitAllowedAlwaysNote = (repo, refs) => `${heldLead(repo, refs)} The user allowed pushes to ${repo} from now on. Run the push again.`;
90
+ /** Asked and declined (also a repository refused earlier in the run). */
91
+ export const gitDeniedNote = (repo, refs, reason) => `${heldLead(repo, refs)} The user declined the push to ${repo}. Do not push it another way; continue without pushing or ask the user.` +
92
+ (reason != null && reason !== "" ? ` Reason: ${reason}` : "");
93
+ /** Asked, no answer within the approval timeout. */
94
+ export const gitExpiredNote = (repo, refs, hours) => `${heldLead(repo, refs)} The user did not answer within ${hours}h; the push was not made.`;
95
+ /** Asked, the person sent a message instead. */
96
+ export const gitSkippedNote = (repo, refs) => `${heldLead(repo, refs)} The user sent a message instead of answering; the push was not made.`;
97
+ /** Asked, the run was stopped before the answer (the note abort writes too). */
98
+ export const gitCancelledNote = () => GIT_ASK_CANCELLED_NOTE;
99
+ /** Not asked: another git push ask of the run tree is waiting (at most one at a time). */
100
+ export const gitOtherAskPendingNote = (repo, refs) => `[git] The push to ${target(repo, refs)} was not made: another push request is waiting for the user. Run it again after that one is answered.`;
101
+ /** Not asked: the run made {@link MAX_GIT_ASKS_PER_RUN} asks already. */
102
+ export const gitTooManyNote = (repo, refs) => `[git] The push to ${target(repo, refs)} was not made: too many push requests in this task. Ask the user to push or to allow ${repo}.`;
103
+ /** An unattended run in `fail` mode: no ask, the creator can add a task grant. */
104
+ export const gitUnattendedNote = (repo, refs) => `[git] The push to ${target(repo, refs)} was not made: this scheduled run cannot ask for approval. The task's owner can allow pushes to ${repo} for the task. Continue without pushing.`;
105
+ /** The org's or the member's policy denies pushes to the repository. */
106
+ export const gitPolicyDeniedNote = (repo, refs) => `[git] The push to ${target(repo, refs)} was not made: pushes to ${repo} are not allowed by this organization's policy. Do not push it another way; continue without pushing or tell the user.`;
107
+ /** The push had no credential: the actor has no connection for the host (spec §6.2). */
108
+ export const gitNotConnectedNote = (host) => `[git] The push to ${host} was not made: the user has no connection that can push there. The user can connect one under Settings → Connectors; continue without pushing or ask the user.`;
109
+ /** How the run state keys a repository on a host: `<host>/<owner>/<repo>` (lower-case). */
110
+ export const gitRepoKey = (host, repo) => `${host.toLowerCase()}/${repo}`;
111
+ /**
112
+ * Decides what a held push becomes after its command (the caller asks only for a foreground
113
+ * `Bash` call): the gateway's own refusals (`unattended`: its note and a missing grant;
114
+ * `denied`: the policy note) are written at once; an ask (`ask`) about a repository refused
115
+ * earlier in the run tree gets the denied note, one while another git ask of the tree is pending
116
+ * or after {@link MAX_GIT_ASKS_PER_RUN} asks of the run gets its note; the rest is asked.
117
+ */
118
+ export function planGitAsk(record, run) {
119
+ const { repo, refs } = record;
120
+ switch (record.outcome) {
121
+ case "unattended":
122
+ return {
123
+ ask: false,
124
+ notes: [gitUnattendedNote(repo, refs)],
125
+ missingGrants: [gitPushGrantKey(repo)],
126
+ };
127
+ case "denied":
128
+ return { ask: false, notes: [gitPolicyDeniedNote(repo, refs)], missingGrants: [] };
129
+ default:
130
+ break;
131
+ }
132
+ if (run?.refused.has(gitRepoKey(record.host, repo))) {
133
+ return { ask: false, notes: [gitDeniedNote(repo, refs)], missingGrants: [] };
134
+ }
135
+ if (run?.pending === true) {
136
+ return { ask: false, notes: [gitOtherAskPendingNote(repo, refs)], missingGrants: [] };
137
+ }
138
+ if ((run?.asks ?? 0) >= MAX_GIT_ASKS_PER_RUN) {
139
+ return { ask: false, notes: [gitTooManyNote(repo, refs)], missingGrants: [] };
140
+ }
141
+ return { ask: true, notes: [], missingGrants: [] };
142
+ }
143
+ /**
144
+ * The note of a settled git ask: one line per outcome; `hours` is the ask's own wait (created to
145
+ * expiry). Null while it is pending or for an approval of another kind.
146
+ */
147
+ export function settledGitNotes(a, hours) {
148
+ if (a.git === null)
149
+ return null;
150
+ const { repo, refs } = a.git;
151
+ switch (a.status) {
152
+ case "approved":
153
+ return [
154
+ a.scope === "always" ? gitAllowedAlwaysNote(repo, refs) : gitAllowedOnceNote(repo, refs),
155
+ ];
156
+ case "denied":
157
+ return [gitDeniedNote(repo, refs, a.denyReason)];
158
+ case "expired":
159
+ return [gitExpiredNote(repo, refs, hours)];
160
+ case "skipped":
161
+ return [gitSkippedNote(repo, refs)];
162
+ case "cancelled":
163
+ return [gitCancelledNote()];
164
+ default:
165
+ return null;
166
+ }
167
+ }
168
+ /** `text` followed by `notes` after a blank line (unchanged without notes). */
169
+ export const withGitNotes = (text, notes) => notes.length === 0 ? text : `${text}\n\n${notes.join("\n")}`;
170
+ /**
171
+ * A kept result's content within 16 KB (UTF-8): the head, then {@link GIT_ASK_CLIPPED_NOTE}. The
172
+ * caller archives large output first, so this rarely cuts.
173
+ */
174
+ export const clipGitKept = (content) => clipKeptWith(content, GIT_ASK_CLIPPED_NOTE);