@capybara-hq/kernel 0.1.0-rc.27 → 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 +15 -3
  3. package/dist/catalog/catalog-content.js +5 -3
  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 +10 -3
  7. package/dist/catalog/plugin-install.d.ts +34 -18
  8. package/dist/catalog/plugin-install.js +84 -46
  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 -5
  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,
@@ -437,6 +440,7 @@ let ConnectorsService = class ConnectorsService {
437
440
  connector: input.connector,
438
441
  endpoint: JSON.stringify(endpoint),
439
442
  auth: JSON.stringify(auth),
443
+ git_hosts: input.git !== undefined ? JSON.stringify(input.git.hosts) : null,
440
444
  created_by: actorAccountId,
441
445
  catalog_item_id: input.template?.itemId ?? null,
442
446
  catalog_version: input.template?.version ?? null,
@@ -523,14 +527,21 @@ let ConnectorsService = class ConnectorsService {
523
527
  */
524
528
  async applyTemplateIn(orgId, connectorId, actorAccountId, p, actorTokenId) {
525
529
  const t = this.db;
526
- await this.row(t, orgId, connectorId, true);
530
+ const before = await this.row(t, orgId, connectorId, true);
527
531
  const { input } = p;
528
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";
529
537
  await t
530
538
  .updateTable("connectors")
531
539
  .set({
532
540
  endpoint: JSON.stringify(p.endpoint),
533
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 }),
534
545
  catalog_item_id: input.template.itemId,
535
546
  catalog_version: input.template.version,
536
547
  template_tool_policies: policies ? JSON.stringify(policies) : null,
@@ -599,6 +610,7 @@ let ConnectorsService = class ConnectorsService {
599
610
  credentialsReset: input.resetCredentials,
600
611
  credentialProvided: p.secret !== undefined || p.oauth !== undefined,
601
612
  policies: policyChanges,
613
+ ...(before.use_for_git && !botCapable && { useForGit: { from: true, to: false } }),
602
614
  },
603
615
  });
604
616
  }
@@ -770,6 +782,8 @@ let ConnectorsService = class ConnectorsService {
770
782
  status: r.status,
771
783
  platformTemplateId: r.platform_template_id ?? null,
772
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,
773
787
  createdBy: r.created_by,
774
788
  createdAt: r.created_at,
775
789
  updatedAt: r.updated_at,
@@ -983,15 +997,34 @@ let ConnectorsService = class ConnectorsService {
983
997
  problems.push({ path, message: issue });
984
998
  }
985
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
+ }
986
1012
  if (problems.length > 0) {
987
1013
  throw new ApiError("validation_failed", "Request validation failed", problems);
988
1014
  }
989
- 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) {
990
1017
  await t
991
1018
  .updateTable("connectors")
992
1019
  .set({
993
1020
  ...(patch.name !== undefined && { name: patch.name }),
994
- ...(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 }),
995
1028
  updated_at: sql `now()`,
996
1029
  })
997
1030
  .where("id", "=", connectorId)
@@ -1041,6 +1074,7 @@ let ConnectorsService = class ConnectorsService {
1041
1074
  detail: {
1042
1075
  ...(patch.name !== undefined && { name: { from: app.name, to: patch.name } }),
1043
1076
  ...(patch.status !== undefined && { status: { from: app.status, to: patch.status } }),
1077
+ ...(gitChanged && { useForGit: { from: app.use_for_git, to: patch.useForGit } }),
1044
1078
  ...(toolChanges.length > 0 && { tools: Object.fromEntries(toolChanges) }),
1045
1079
  ...(allowed.length > 0 && { allowedUnannotated: allowed }),
1046
1080
  },
@@ -1064,6 +1098,57 @@ let ConnectorsService = class ConnectorsService {
1064
1098
  throw notFound();
1065
1099
  return r.org_id;
1066
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
+ }
1067
1152
  /**
1068
1153
  * Disables or enables a connector on the platform's behalf (a super admin who need not be a
1069
1154
  * member of the org; the caller checks the super admin): org row lock, the status update, audit
@@ -1077,7 +1162,9 @@ let ConnectorsService = class ConnectorsService {
1077
1162
  const app = await this.row(t, orgId, connectorId, true);
1078
1163
  await t
1079
1164
  .updateTable("connectors")
1080
- .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()` })
1081
1168
  .where("id", "=", connectorId)
1082
1169
  .execute();
1083
1170
  await audit(t, {
@@ -1384,6 +1471,12 @@ __decorate([
1384
1471
  __decorate([
1385
1472
  Transactional()
1386
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);
1387
1480
  __decorate([
1388
1481
  Transactional()
1389
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);