@runuai/host 0.9.10 → 0.9.12
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/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +18 -0
- package/images/standard/container/code-server-settings.json +38 -11
- package/images/standard/container/uai-init +33 -2
- package/lib/env.ts +6 -1
- package/lib/github-tokens.ts +633 -69
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +23 -7
- package/src/main.ts +225 -22
- package/src/protocol.ts +113 -8
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -1081,6 +1081,13 @@ fi
|
|
|
1081
1081
|
# Projects may declare managed names, so skip them here rather than trust
|
|
1082
1082
|
# callers. They are also removed from the in-memory Compose override below.
|
|
1083
1083
|
#
|
|
1084
|
+
# UAI_EDITOR_THEME is reserved in BOTH places for the case that is easy to
|
|
1085
|
+
# miss: a task whose stored palette is null, on a project that happens to
|
|
1086
|
+
# declare that name. The cloud sends no theme, so the `preview_env` entry
|
|
1087
|
+
# that normally shadows a declared key is absent too — and without these two
|
|
1088
|
+
# lines a project would get to pick what the Editor pane looks like. Reserved,
|
|
1089
|
+
# the name is not emitted at all and uai-init seeds the image's own default.
|
|
1090
|
+
#
|
|
1084
1091
|
# Project values never enter the host environment. Emit an explicit empty
|
|
1085
1092
|
# default for every declared key; the stdin-only Compose override supplies
|
|
1086
1093
|
# decrypted values at `compose up` time.
|
|
@@ -1094,15 +1101,23 @@ fi
|
|
|
1094
1101
|
GIT_SSH | GIT_SSH_COMMAND | HOME | XDG_CONFIG_HOME | XDG_DATA_HOME | \
|
|
1095
1102
|
BASH_ENV | ENV | UAI_GIT_TRANSPORT | \
|
|
1096
1103
|
CLAUDE_CODE_OAUTH_TOKEN | ANTHROPIC_API_KEY | \
|
|
1097
|
-
ANTHROPIC_AUTH_TOKEN | XAI_API_KEY | PLAYWRIGHT_BROWSERS_PATH
|
|
1104
|
+
ANTHROPIC_AUTH_TOKEN | XAI_API_KEY | PLAYWRIGHT_BROWSERS_PATH | \
|
|
1105
|
+
UAI_EDITOR_THEME) continue ;;
|
|
1098
1106
|
esac
|
|
1099
1107
|
printf ' %s: ""\n' "$ekey"
|
|
1100
1108
|
done < <(jq -r '.[]' <<<"$union_env_keys_json")
|
|
1101
|
-
#
|
|
1102
|
-
#
|
|
1103
|
-
#
|
|
1104
|
-
#
|
|
1105
|
-
#
|
|
1109
|
+
# Cloud-computed literal env (ADR-025): a JSON object {VAR: value} on the
|
|
1110
|
+
# task row. Non-secret, so written LITERALLY (unlike the ${KEY:-}
|
|
1111
|
+
# pass-throughs above). Emitted last so it wins over any same-named declared
|
|
1112
|
+
# key. Two producers, both cloud-side and both opaque here:
|
|
1113
|
+
# - PUBLIC preview URLs under operator-chosen names (EXPO_PACKAGER_PROXY_URL
|
|
1114
|
+
# and friends), one per preview port that set `urlEnv`;
|
|
1115
|
+
# - UAI_EDITOR_THEME (ADR-091), the Editor pane's colour theme as a VS Code
|
|
1116
|
+
# theme LABEL ("Quiet Light"), already resolved from the palette the
|
|
1117
|
+
# creating browser snapshotted. The host never maps a Uai palette id.
|
|
1118
|
+
# UAI_EDITOR_THEME is absent for every task created before it shipped and for
|
|
1119
|
+
# any created outside a browser; uai-init then seeds the image's own default,
|
|
1120
|
+
# so the column staying null is a supported state, not a value to fill in.
|
|
1106
1121
|
preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
|
|
1107
1122
|
while IFS= read -r pe_entry; do
|
|
1108
1123
|
[ -n "$pe_entry" ] || continue
|
|
@@ -1188,7 +1203,8 @@ compose_env_override=$(printf '%s' "$project_env_json" | jq -c \
|
|
|
1188
1203
|
.ANTHROPIC_API_KEY,
|
|
1189
1204
|
.ANTHROPIC_AUTH_TOKEN,
|
|
1190
1205
|
.XAI_API_KEY,
|
|
1191
|
-
.PLAYWRIGHT_BROWSERS_PATH
|
|
1206
|
+
.PLAYWRIGHT_BROWSERS_PATH,
|
|
1207
|
+
.UAI_EDITOR_THEME
|
|
1192
1208
|
)
|
|
1193
1209
|
| delpaths($preview | keys | map([.]))
|
|
1194
1210
|
| {services:{app:{environment:.}}}
|
package/src/main.ts
CHANGED
|
@@ -25,12 +25,17 @@ import {
|
|
|
25
25
|
import { getHostTask } from "../lib/runtime-state";
|
|
26
26
|
import { getOrchestrator, recoveryComplete } from "../lib/orchestrator";
|
|
27
27
|
import {
|
|
28
|
+
claimGithubCredentialGeneration,
|
|
28
29
|
connectedUserIds,
|
|
30
|
+
GitHubRepositoryAccessError,
|
|
29
31
|
isTransientGithubError,
|
|
32
|
+
listUserInstallationIds,
|
|
33
|
+
listUserInstallationRepositoryIds,
|
|
30
34
|
onConnectClear,
|
|
31
35
|
onConnectSet,
|
|
32
36
|
onGithubChange,
|
|
33
37
|
reconcileTaskGitAuth,
|
|
38
|
+
rollbackClaimedGitHubCredentialSet,
|
|
34
39
|
runGithubConnectionTransition,
|
|
35
40
|
setAuthExpiredHandler,
|
|
36
41
|
} from "../lib/github-tokens";
|
|
@@ -79,6 +84,9 @@ import { ensureStandardImage, standardRuntimes } from "../lib/standard-image";
|
|
|
79
84
|
import { hostCommands, hostEvents } from "./index";
|
|
80
85
|
import {
|
|
81
86
|
HostErrorCode,
|
|
87
|
+
GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE,
|
|
88
|
+
GITHUB_INSTALLATION_VERIFICATION_PROTOCOL_FEATURE,
|
|
89
|
+
GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE,
|
|
82
90
|
SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
|
|
83
91
|
TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
|
|
84
92
|
type CloudToHost,
|
|
@@ -90,6 +98,7 @@ import {
|
|
|
90
98
|
type HostCommands,
|
|
91
99
|
type HostToCloud,
|
|
92
100
|
type PermissionDecision,
|
|
101
|
+
parseGitHubCredentialGeneration,
|
|
93
102
|
parseChannelMode,
|
|
94
103
|
parseTranscriptTargets,
|
|
95
104
|
type TaskAgent,
|
|
@@ -225,6 +234,9 @@ function buildCapabilities(): HostCapabilities {
|
|
|
225
234
|
version: packageVersion(),
|
|
226
235
|
protocolFeatures: [
|
|
227
236
|
TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
|
|
237
|
+
GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE,
|
|
238
|
+
GITHUB_INSTALLATION_VERIFICATION_PROTOCOL_FEATURE,
|
|
239
|
+
GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE,
|
|
228
240
|
// The echo adapter cannot execute the in-task CLI. Advertising typed
|
|
229
241
|
// dispatch in mock mode would let the composer create a Secretary that
|
|
230
242
|
// has no way to wake crew.
|
|
@@ -353,24 +365,67 @@ function connect(): void {
|
|
|
353
365
|
case "gh.connect.set": {
|
|
354
366
|
// Account switches fence every host-side Git operation using the old
|
|
355
367
|
// credential before the replacement grant is stored or reinjected.
|
|
356
|
-
void runGithubConnectionTransition(frame.userId, () =>
|
|
357
|
-
|
|
368
|
+
void runGithubConnectionTransition(frame.userId, async () => {
|
|
369
|
+
const claim = claimGithubCredentialGeneration(
|
|
370
|
+
frame.userId,
|
|
371
|
+
frame.generation,
|
|
372
|
+
"set",
|
|
373
|
+
);
|
|
374
|
+
if (!claim) {
|
|
375
|
+
return {
|
|
376
|
+
ok: false as const,
|
|
377
|
+
code: "stale_generation" as const,
|
|
378
|
+
error: "stale GitHub credential generation",
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const transitionDeps = {
|
|
358
382
|
invalidateCredentials: invalidateTaskGithubGitCredentials,
|
|
359
|
-
reconcile: (taskId, userId) =>
|
|
383
|
+
reconcile: (taskId: string, userId: string) =>
|
|
360
384
|
getOrchestrator().runTaskLifecycle(taskId, () =>
|
|
361
385
|
reconcileTaskGitAuth(taskId, userId),
|
|
362
386
|
),
|
|
363
|
-
}
|
|
364
|
-
|
|
387
|
+
};
|
|
388
|
+
let result: Awaited<ReturnType<typeof onConnectSet>>;
|
|
389
|
+
try {
|
|
390
|
+
result = await onConnectSet(
|
|
391
|
+
{ ...frame, generation: claim.generation },
|
|
392
|
+
transitionDeps,
|
|
393
|
+
);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
result = {
|
|
396
|
+
ok: false,
|
|
397
|
+
error: err instanceof Error ? err.message : String(err),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
if (!result.ok) {
|
|
401
|
+
// Storage can succeed before live-task reconciliation fails. Never
|
|
402
|
+
// emit a negative set ack while that untrusted credential remains
|
|
403
|
+
// active: persist the same-generation clear tombstone first, then
|
|
404
|
+
// best-effort scrub/revoke under the same serialized transition.
|
|
405
|
+
await rollbackClaimedGitHubCredentialSet(
|
|
406
|
+
frame.userId,
|
|
407
|
+
claim.generation,
|
|
408
|
+
transitionDeps,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
return result;
|
|
412
|
+
}).then(
|
|
365
413
|
(result) =>
|
|
366
414
|
send(
|
|
367
415
|
socket,
|
|
368
416
|
result.ok
|
|
369
|
-
? {
|
|
417
|
+
? {
|
|
418
|
+
kind: "gh.connect.ack",
|
|
419
|
+
opId: frame.opId,
|
|
420
|
+
userId: frame.userId,
|
|
421
|
+
ok: true,
|
|
422
|
+
}
|
|
370
423
|
: {
|
|
371
424
|
kind: "gh.connect.ack",
|
|
425
|
+
opId: frame.opId,
|
|
372
426
|
userId: frame.userId,
|
|
373
427
|
ok: false,
|
|
428
|
+
code: "code" in result ? result.code : undefined,
|
|
374
429
|
error: result.error ?? "store failed",
|
|
375
430
|
},
|
|
376
431
|
),
|
|
@@ -379,6 +434,7 @@ function connect(): void {
|
|
|
379
434
|
console.warn(`[github] connect.set failed: ${error}`);
|
|
380
435
|
send(socket, {
|
|
381
436
|
kind: "gh.connect.ack",
|
|
437
|
+
opId: frame.opId,
|
|
382
438
|
userId: frame.userId,
|
|
383
439
|
ok: false,
|
|
384
440
|
error,
|
|
@@ -387,31 +443,130 @@ function connect(): void {
|
|
|
387
443
|
);
|
|
388
444
|
break;
|
|
389
445
|
}
|
|
446
|
+
case "gh.installations.list": {
|
|
447
|
+
// Read-only: no token leaves the host, only the installation ids
|
|
448
|
+
// GitHub attests to for this user. A failure acks ok:false rather
|
|
449
|
+
// than an empty list — the cloud prunes bindings absent from the
|
|
450
|
+
// result, and "couldn't ask" must never be read as "installed
|
|
451
|
+
// nowhere".
|
|
452
|
+
void listUserInstallationIds(
|
|
453
|
+
frame.userId,
|
|
454
|
+
{},
|
|
455
|
+
frame.generation ?? 0,
|
|
456
|
+
).then(
|
|
457
|
+
(installationIds) =>
|
|
458
|
+
send(socket, {
|
|
459
|
+
kind: "gh.installations.ack",
|
|
460
|
+
opId: frame.opId,
|
|
461
|
+
ok: true,
|
|
462
|
+
installationIds,
|
|
463
|
+
}),
|
|
464
|
+
(err) => {
|
|
465
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
466
|
+
const code =
|
|
467
|
+
err instanceof GitHubRepositoryAccessError
|
|
468
|
+
? err.code
|
|
469
|
+
: "github_unavailable";
|
|
470
|
+
console.warn(
|
|
471
|
+
`[github] installation list failed for ${frame.userId}: ${error}`,
|
|
472
|
+
);
|
|
473
|
+
send(socket, {
|
|
474
|
+
kind: "gh.installations.ack",
|
|
475
|
+
opId: frame.opId,
|
|
476
|
+
ok: false,
|
|
477
|
+
code,
|
|
478
|
+
error,
|
|
479
|
+
});
|
|
480
|
+
},
|
|
481
|
+
);
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
case "gh.repositories.list": {
|
|
485
|
+
void listUserInstallationRepositoryIds(
|
|
486
|
+
frame.userId,
|
|
487
|
+
frame.installationId,
|
|
488
|
+
{},
|
|
489
|
+
frame.generation ?? 0,
|
|
490
|
+
).then(
|
|
491
|
+
({ repositoryIds, truncated }) =>
|
|
492
|
+
send(socket, {
|
|
493
|
+
kind: "gh.repositories.ack",
|
|
494
|
+
opId: frame.opId,
|
|
495
|
+
ok: true,
|
|
496
|
+
repositoryIds,
|
|
497
|
+
truncated,
|
|
498
|
+
}),
|
|
499
|
+
(err) => {
|
|
500
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
501
|
+
const code =
|
|
502
|
+
err instanceof GitHubRepositoryAccessError
|
|
503
|
+
? err.code
|
|
504
|
+
: "github_unavailable";
|
|
505
|
+
console.warn(
|
|
506
|
+
`[github] repository list failed for ${frame.userId}/${frame.installationId}: ${error}`,
|
|
507
|
+
);
|
|
508
|
+
send(socket, {
|
|
509
|
+
kind: "gh.repositories.ack",
|
|
510
|
+
opId: frame.opId,
|
|
511
|
+
ok: false,
|
|
512
|
+
code,
|
|
513
|
+
error,
|
|
514
|
+
});
|
|
515
|
+
},
|
|
516
|
+
);
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
390
519
|
case "gh.connect.clear": {
|
|
391
|
-
//
|
|
392
|
-
// remote
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
520
|
+
// The serialized transition removes the local credential, waits for
|
|
521
|
+
// best-effort remote revocation, and scrubs live containers before it
|
|
522
|
+
// acknowledges. That keeps a delayed revoke from racing a later set.
|
|
523
|
+
void runGithubConnectionTransition(frame.userId, async () => {
|
|
524
|
+
const claim = claimGithubCredentialGeneration(
|
|
525
|
+
frame.userId,
|
|
526
|
+
frame.generation,
|
|
527
|
+
"clear",
|
|
528
|
+
);
|
|
529
|
+
if (!claim) {
|
|
530
|
+
return {
|
|
531
|
+
ok: false as const,
|
|
532
|
+
code: "stale_generation" as const,
|
|
533
|
+
error: "stale GitHub credential generation",
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
await onConnectClear(frame.userId, {
|
|
397
537
|
invalidateCredentials: invalidateTaskGithubGitCredentials,
|
|
398
538
|
reconcile: (taskId, userId) =>
|
|
399
539
|
getOrchestrator().runTaskLifecycle(taskId, () =>
|
|
400
540
|
reconcileTaskGitAuth(taskId, userId),
|
|
401
541
|
),
|
|
402
|
-
})
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
ok
|
|
409
|
-
|
|
542
|
+
});
|
|
543
|
+
return { ok: true as const };
|
|
544
|
+
}).then(
|
|
545
|
+
(result) =>
|
|
546
|
+
send(
|
|
547
|
+
socket,
|
|
548
|
+
result.ok
|
|
549
|
+
? {
|
|
550
|
+
kind: "gh.connect.ack",
|
|
551
|
+
opId: frame.opId,
|
|
552
|
+
userId: frame.userId,
|
|
553
|
+
ok: true,
|
|
554
|
+
}
|
|
555
|
+
: {
|
|
556
|
+
kind: "gh.connect.ack",
|
|
557
|
+
opId: frame.opId,
|
|
558
|
+
userId: frame.userId,
|
|
559
|
+
ok: false,
|
|
560
|
+
code: result.code,
|
|
561
|
+
error: result.error,
|
|
562
|
+
},
|
|
563
|
+
),
|
|
410
564
|
(err) => {
|
|
411
565
|
const error = err instanceof Error ? err.message : String(err);
|
|
412
566
|
console.warn(`[github] connect.clear failed: ${error}`);
|
|
413
567
|
send(socket, {
|
|
414
568
|
kind: "gh.connect.ack",
|
|
569
|
+
opId: frame.opId,
|
|
415
570
|
userId: frame.userId,
|
|
416
571
|
ok: false,
|
|
417
572
|
error,
|
|
@@ -1127,12 +1282,17 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
|
|
|
1127
1282
|
typeof frame.installationId === "number" &&
|
|
1128
1283
|
typeof frame.githubLogin === "string" &&
|
|
1129
1284
|
(frame.targetType === "User" || frame.targetType === "Organization") &&
|
|
1285
|
+
(frame.opId === undefined || typeof frame.opId === "string") &&
|
|
1286
|
+
parseGitHubCredentialGeneration(frame.generation) !== null &&
|
|
1130
1287
|
// Exactly one token kind: accessToken (ADR-033) or refreshToken (ADR-027).
|
|
1131
1288
|
(typeof frame.accessToken === "string" ||
|
|
1132
1289
|
typeof frame.refreshToken === "string")
|
|
1133
1290
|
) {
|
|
1134
1291
|
return {
|
|
1135
1292
|
kind: "gh.connect.set",
|
|
1293
|
+
opId: typeof frame.opId === "string" ? frame.opId : undefined,
|
|
1294
|
+
generation:
|
|
1295
|
+
parseGitHubCredentialGeneration(frame.generation) ?? undefined,
|
|
1136
1296
|
userId: frame.userId,
|
|
1137
1297
|
installationId: frame.installationId,
|
|
1138
1298
|
githubLogin: frame.githubLogin,
|
|
@@ -1147,8 +1307,51 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
|
|
|
1147
1307
|
: undefined,
|
|
1148
1308
|
};
|
|
1149
1309
|
}
|
|
1150
|
-
if (
|
|
1151
|
-
|
|
1310
|
+
if (
|
|
1311
|
+
frame.kind === "gh.connect.clear" &&
|
|
1312
|
+
typeof frame.userId === "string" &&
|
|
1313
|
+
(frame.opId === undefined || typeof frame.opId === "string") &&
|
|
1314
|
+
parseGitHubCredentialGeneration(frame.generation) !== null
|
|
1315
|
+
) {
|
|
1316
|
+
return {
|
|
1317
|
+
kind: "gh.connect.clear",
|
|
1318
|
+
opId: typeof frame.opId === "string" ? frame.opId : undefined,
|
|
1319
|
+
generation:
|
|
1320
|
+
parseGitHubCredentialGeneration(frame.generation) ?? undefined,
|
|
1321
|
+
userId: frame.userId,
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
if (
|
|
1325
|
+
frame.kind === "gh.installations.list" &&
|
|
1326
|
+
typeof frame.opId === "string" &&
|
|
1327
|
+
typeof frame.userId === "string" &&
|
|
1328
|
+
parseGitHubCredentialGeneration(frame.generation) !== null
|
|
1329
|
+
) {
|
|
1330
|
+
return {
|
|
1331
|
+
kind: "gh.installations.list",
|
|
1332
|
+
opId: frame.opId,
|
|
1333
|
+
generation:
|
|
1334
|
+
parseGitHubCredentialGeneration(frame.generation) ?? undefined,
|
|
1335
|
+
userId: frame.userId,
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
if (
|
|
1339
|
+
frame.kind === "gh.repositories.list" &&
|
|
1340
|
+
typeof frame.opId === "string" &&
|
|
1341
|
+
typeof frame.userId === "string" &&
|
|
1342
|
+
typeof frame.installationId === "number" &&
|
|
1343
|
+
Number.isSafeInteger(frame.installationId) &&
|
|
1344
|
+
frame.installationId > 0 &&
|
|
1345
|
+
parseGitHubCredentialGeneration(frame.generation) !== null
|
|
1346
|
+
) {
|
|
1347
|
+
return {
|
|
1348
|
+
kind: "gh.repositories.list",
|
|
1349
|
+
opId: frame.opId,
|
|
1350
|
+
generation:
|
|
1351
|
+
parseGitHubCredentialGeneration(frame.generation) ?? undefined,
|
|
1352
|
+
userId: frame.userId,
|
|
1353
|
+
installationId: frame.installationId,
|
|
1354
|
+
};
|
|
1152
1355
|
}
|
|
1153
1356
|
if (
|
|
1154
1357
|
(frame.kind === "ssh.key.get" ||
|
package/src/protocol.ts
CHANGED
|
@@ -35,11 +35,23 @@ export type HostCommandResult<T> =
|
|
|
35
35
|
export const TRANSCRIPT_TARGETS_PROTOCOL_FEATURE = "transcript-targets-v1";
|
|
36
36
|
export const SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE =
|
|
37
37
|
"secretary-typed-dispatch-v1";
|
|
38
|
+
export const GITHUB_INSTALLATION_VERIFICATION_PROTOCOL_FEATURE =
|
|
39
|
+
"github-installation-verification-v1";
|
|
40
|
+
export const GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE =
|
|
41
|
+
"github-repository-access-v1";
|
|
42
|
+
export const GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE =
|
|
43
|
+
"github-credential-generation-v1";
|
|
38
44
|
export const COMMUNICATOR_EXECUTION_PROFILE = "communicator";
|
|
39
45
|
export const MAX_AGENT_ID_CHARS = 128;
|
|
40
46
|
export const MAX_SECRETARY_DISPATCH_RECIPIENTS = 16;
|
|
41
47
|
export const MAX_SECRETARY_DISPATCH_INSTRUCTION_CHARS = 8_000;
|
|
42
48
|
export const MAX_SECRETARY_DISPATCH_ID_CHARS = 256;
|
|
49
|
+
// Wire and GitHub-call bounds for the host-assisted authorization snapshot.
|
|
50
|
+
// Exceeding either bound fails the whole verification; a partial installation
|
|
51
|
+
// list could cause destructive pruning, while a partial repository list is an
|
|
52
|
+
// explicit false-negative allowlist and therefore carries `truncated: true`.
|
|
53
|
+
export const MAX_GITHUB_INSTALLATIONS = 100;
|
|
54
|
+
export const MAX_GITHUB_REPOSITORIES_PER_INSTALLATION = 1_000;
|
|
43
55
|
|
|
44
56
|
export interface CommandContext {
|
|
45
57
|
commandId: string;
|
|
@@ -133,6 +145,19 @@ export function parseChannelMode(
|
|
|
133
145
|
throw new Error("invalid command args: expected channel mode");
|
|
134
146
|
}
|
|
135
147
|
|
|
148
|
+
/** Optional during rolling upgrades; `null` is the explicit invalid sentinel
|
|
149
|
+
* so generation zero remains a valid migrated credential generation. */
|
|
150
|
+
export function parseGitHubCredentialGeneration(
|
|
151
|
+
value: unknown,
|
|
152
|
+
): number | undefined | null {
|
|
153
|
+
if (value === undefined) return undefined;
|
|
154
|
+
return typeof value === "number" &&
|
|
155
|
+
Number.isSafeInteger(value) &&
|
|
156
|
+
value >= 0
|
|
157
|
+
? value
|
|
158
|
+
: null;
|
|
159
|
+
}
|
|
160
|
+
|
|
136
161
|
/** Parse the appendTranscript wire argument. `undefined` alone is the legacy
|
|
137
162
|
* three-argument command and maps to chat.md during a rolling deploy. */
|
|
138
163
|
export function parseTranscriptTargets(value: unknown): TranscriptTarget[] {
|
|
@@ -199,11 +224,17 @@ export interface TaskCommandTask {
|
|
|
199
224
|
reviewerOrder?: string[];
|
|
200
225
|
agents: TaskAgent[];
|
|
201
226
|
/**
|
|
202
|
-
* Cloud-computed
|
|
203
|
-
*
|
|
204
|
-
* `
|
|
205
|
-
*
|
|
206
|
-
*
|
|
227
|
+
* Cloud-computed literal env to inject into the container at task-up.
|
|
228
|
+
* Non-secret by construction — written straight into the task's compose file,
|
|
229
|
+
* unlike the `${KEY:-}` pass-throughs used for credentials. Only present on
|
|
230
|
+
* task-up commands.
|
|
231
|
+
*
|
|
232
|
+
* Named for its first user, PUBLIC preview URLs keyed by the operator-chosen
|
|
233
|
+
* var name (e.g. `EXPO_PACKAGER_PROXY_URL`, present when a preview port set
|
|
234
|
+
* `urlEnv` and the cloud has a preview base domain). It now also carries
|
|
235
|
+
* `UAI_EDITOR_THEME` (ADR-091) — same shape, same guarantees, same one-way
|
|
236
|
+
* trip to `services.app.environment`, so it rides here rather than growing
|
|
237
|
+
* the protocol a second env channel. The host treats every entry as opaque.
|
|
207
238
|
*/
|
|
208
239
|
previewEnv?: Record<string, string>;
|
|
209
240
|
/** ADR-062: owning org — locates the org shared-files root on the host. */
|
|
@@ -462,6 +493,13 @@ export interface TunnelReqLine {
|
|
|
462
493
|
|
|
463
494
|
export type TunnelTarget = "editor" | "preview";
|
|
464
495
|
|
|
496
|
+
export type GitHubRepositoryAccessErrorCode =
|
|
497
|
+
| "token_missing"
|
|
498
|
+
| "reauth_required"
|
|
499
|
+
| "installation_inaccessible"
|
|
500
|
+
| "github_unavailable"
|
|
501
|
+
| "invalid_response";
|
|
502
|
+
|
|
465
503
|
export type CloudToHost =
|
|
466
504
|
| {
|
|
467
505
|
kind: "command";
|
|
@@ -500,6 +538,12 @@ export type CloudToHost =
|
|
|
500
538
|
// for short-lived access tokens (host→cloud HTTP POST).
|
|
501
539
|
| {
|
|
502
540
|
kind: "gh.connect.set";
|
|
541
|
+
/** Correlates the stored credential with the exact host acknowledgement.
|
|
542
|
+
* Optional only for rolling compatibility with pre-correlation clouds. */
|
|
543
|
+
opId?: string;
|
|
544
|
+
/** Monotonic cloud-issued mutation fence. Optional only for rolling
|
|
545
|
+
* compatibility; generation-aware clouds require the matching feature. */
|
|
546
|
+
generation?: number;
|
|
503
547
|
userId: string;
|
|
504
548
|
installationId: number;
|
|
505
549
|
githubLogin: string;
|
|
@@ -510,7 +554,31 @@ export type CloudToHost =
|
|
|
510
554
|
}
|
|
511
555
|
// Delete the user's token on the host. ADR-033: the host first POSTs the
|
|
512
556
|
// token to the cloud's /api/github/revoke (kill it at GitHub) before deleting.
|
|
513
|
-
| {
|
|
557
|
+
| {
|
|
558
|
+
kind: "gh.connect.clear";
|
|
559
|
+
opId?: string;
|
|
560
|
+
generation?: number;
|
|
561
|
+
userId: string;
|
|
562
|
+
}
|
|
563
|
+
// Ask the host which installations GitHub currently lists for a user, using
|
|
564
|
+
// the token the host already holds. Used to prune historical owner
|
|
565
|
+
// misbindings before any legacy row is trusted.
|
|
566
|
+
| {
|
|
567
|
+
kind: "gh.installations.list";
|
|
568
|
+
opId: string;
|
|
569
|
+
generation?: number;
|
|
570
|
+
userId: string;
|
|
571
|
+
}
|
|
572
|
+
// Renew one installation's App ∩ user repository allowlist without sending
|
|
573
|
+
// the user's token to the cloud. Kept separate from installation listing so
|
|
574
|
+
// ordinary one-row lease renewal is bounded and independently retryable.
|
|
575
|
+
| {
|
|
576
|
+
kind: "gh.repositories.list";
|
|
577
|
+
opId: string;
|
|
578
|
+
generation?: number;
|
|
579
|
+
userId: string;
|
|
580
|
+
installationId: number;
|
|
581
|
+
}
|
|
514
582
|
// Per-user SSH key lifecycle (ADR-029). Keys are generated + stored on the
|
|
515
583
|
// host; only the public key crosses the bridge (ssh.key.ack). `get` fetches
|
|
516
584
|
// without creating, `ensure` creates-if-absent, `delete` removes.
|
|
@@ -588,8 +656,45 @@ export type HostToCloud =
|
|
|
588
656
|
| { kind: "tunnel.data"; tunnelId: string }
|
|
589
657
|
| { kind: "tunnel.close"; tunnelId: string; reason?: string }
|
|
590
658
|
// Ack for gh.connect.set / gh.connect.clear (ADR-027).
|
|
591
|
-
| { kind: "gh.connect.ack"; userId: string; ok: true }
|
|
592
|
-
| {
|
|
659
|
+
| { kind: "gh.connect.ack"; opId?: string; userId: string; ok: true }
|
|
660
|
+
| {
|
|
661
|
+
kind: "gh.connect.ack";
|
|
662
|
+
opId?: string;
|
|
663
|
+
userId: string;
|
|
664
|
+
ok: false;
|
|
665
|
+
code?: "stale_generation";
|
|
666
|
+
error: string;
|
|
667
|
+
}
|
|
668
|
+
// Ack for gh.installations.list. `installationIds` is GitHub's current
|
|
669
|
+
// verdict and is authoritative ONLY on ok:true. An error must never be read
|
|
670
|
+
// as "installed nowhere", which would delete every binding the user has.
|
|
671
|
+
| {
|
|
672
|
+
kind: "gh.installations.ack";
|
|
673
|
+
opId: string;
|
|
674
|
+
ok: true;
|
|
675
|
+
installationIds: number[];
|
|
676
|
+
}
|
|
677
|
+
| {
|
|
678
|
+
kind: "gh.installations.ack";
|
|
679
|
+
opId: string;
|
|
680
|
+
ok: false;
|
|
681
|
+
code: GitHubRepositoryAccessErrorCode;
|
|
682
|
+
error: string;
|
|
683
|
+
}
|
|
684
|
+
| {
|
|
685
|
+
kind: "gh.repositories.ack";
|
|
686
|
+
opId: string;
|
|
687
|
+
ok: true;
|
|
688
|
+
repositoryIds: number[];
|
|
689
|
+
truncated: boolean;
|
|
690
|
+
}
|
|
691
|
+
| {
|
|
692
|
+
kind: "gh.repositories.ack";
|
|
693
|
+
opId: string;
|
|
694
|
+
ok: false;
|
|
695
|
+
code: GitHubRepositoryAccessErrorCode;
|
|
696
|
+
error: string;
|
|
697
|
+
}
|
|
593
698
|
// Ack for ssh.key.get / ssh.key.ensure / ssh.key.delete (ADR-029). publicKey
|
|
594
699
|
// is null when the user has no key (after delete, or a get-miss).
|
|
595
700
|
| { kind: "ssh.key.ack"; userId: string; ok: true; publicKey: string | null }
|