@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.
- package/dist/app.js +2 -0
- package/dist/catalog/catalog-content.d.ts +15 -3
- package/dist/catalog/catalog-content.js +5 -3
- package/dist/catalog/catalog-scope.service.js +3 -2
- package/dist/catalog/marketplaces.service.d.ts +1 -1
- package/dist/catalog/org-catalog.service.js +10 -3
- package/dist/catalog/plugin-install.d.ts +34 -18
- package/dist/catalog/plugin-install.js +84 -46
- package/dist/catalog/plugin-upgrade.js +1 -0
- package/dist/channels/actions.d.ts +4 -1
- package/dist/channels/actions.js +5 -2
- package/dist/channels/cards.d.ts +7 -1
- package/dist/channels/cards.js +28 -1
- package/dist/channels/phrases.d.ts +15 -0
- package/dist/channels/phrases.js +9 -0
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.js +69 -0
- package/dist/conversations/events.d.ts +14 -0
- package/dist/conversations/events.js +20 -0
- package/dist/db/kernel-schema.d.ts +19 -4
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +5 -0
- package/dist/git/gateway.d.ts +93 -0
- package/dist/git/gateway.js +750 -0
- package/dist/git/gateway.route.d.ts +16 -0
- package/dist/git/gateway.route.js +41 -0
- package/dist/git/git.module.d.ts +10 -0
- package/dist/git/git.module.js +31 -0
- package/dist/git/identity.d.ts +80 -0
- package/dist/git/identity.js +209 -0
- package/dist/git/pkt-line.d.ts +53 -0
- package/dist/git/pkt-line.js +146 -0
- package/dist/git/run-end.d.ts +29 -0
- package/dist/git/run-end.js +121 -0
- package/dist/git/sources.d.ts +42 -0
- package/dist/git/sources.js +134 -0
- package/dist/git/tickets.d.ts +69 -0
- package/dist/git/tickets.js +194 -0
- package/dist/http/api.module.js +3 -0
- package/dist/http/app.d.ts +6 -0
- package/dist/http/app.js +3 -0
- package/dist/infra/config/kernel-config.service.js +2 -0
- package/dist/orgs/settings.service.d.ts +21 -0
- package/dist/orgs/settings.service.js +18 -0
- package/dist/overrides.d.ts +6 -0
- package/dist/overrides.js +4 -0
- package/dist/platform/exclusions.js +3 -0
- package/dist/runtime/run-finish.d.ts +5 -0
- package/dist/runtime/run-finish.js +12 -3
- package/dist/runtime/runtime.module.js +2 -0
- package/dist/runtime/waits.js +7 -5
- package/dist/schedules/fire.d.ts +3 -3
- package/dist/schedules/fire.js +7 -4
- package/dist/schedules/grant-rules.d.ts +40 -0
- package/dist/schedules/grant-rules.js +83 -0
- package/dist/schedules/scheduled-tasks.service.d.ts +9 -7
- package/dist/schedules/scheduled-tasks.service.js +50 -15
- package/dist/schedules/types.d.ts +2 -0
- package/dist/tools/approvals.service.d.ts +100 -19
- package/dist/tools/approvals.service.js +288 -21
- package/dist/tools/builtin-names.d.ts +4 -2
- package/dist/tools/builtin-names.js +6 -1
- package/dist/tools/builtins/schedules.d.ts +11 -9
- package/dist/tools/builtins/schedules.js +202 -44
- package/dist/tools/connector-account-policies.d.ts +8 -0
- package/dist/tools/connector-account-policies.js +8 -0
- package/dist/tools/connectors.controller.d.ts +2 -0
- package/dist/tools/connectors.controller.js +5 -1
- package/dist/tools/connectors.service.d.ts +46 -3
- package/dist/tools/connectors.service.js +98 -5
- package/dist/tools/credentials.d.ts +18 -0
- package/dist/tools/credentials.js +60 -0
- package/dist/tools/credentials.module.d.ts +7 -0
- package/dist/tools/credentials.module.js +22 -0
- package/dist/tools/git-asks.d.ts +113 -0
- package/dist/tools/git-asks.js +174 -0
- package/dist/tools/git-push-policy.d.ts +89 -0
- package/dist/tools/git-push-policy.js +175 -0
- package/dist/tools/info.d.ts +3 -3
- package/dist/tools/info.js +3 -3
- package/dist/tools/network-asks.d.ts +5 -0
- package/dist/tools/network-asks.js +9 -2
- package/dist/tools/tools.module.js +4 -3
- package/package.json +3 -3
- package/src/migrations/0126_git_gateway.sql +10 -0
- package/src/migrations/0127_git_push_policy.sql +12 -0
- package/src/migrations/0128_scheduled_task_plugin_disabled.sql +10 -0
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|