Skip to content

Commit bd0dbef

Browse files
committed
fix(approvals): close trusted runtime lifecycle gaps
1 parent 53a9b42 commit bd0dbef

16 files changed

Lines changed: 501 additions & 79 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ jobs:
9999
- name: Verify runtime lock
100100
run: node ./scripts/ci/verify-runtime-lock.mjs
101101

102+
- name: Verify hosted approval runtime authority golden
103+
run: node ./scripts/verify-hosted-approval-runtime-admission-authority.mjs --verify-golden
104+
102105
- name: Resolve and fetch architecture baseline
103106
id: architecture-baseline
104107
env:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"guard:feature-architecture:baseline": "node ./scripts/ci/update-feature-architecture-baseline.mjs --write",
8484
"guard:hosted-phase0-evidence": "node ./scripts/ci/verify-hosted-phase0-evidence-integrity.mjs",
8585
"guard:runtime-artifacts": "node ./scripts/ci/forbid-runtime-artifacts.cjs",
86+
"guard:hosted-approval-runtime-authority": "node ./scripts/verify-hosted-approval-runtime-admission-authority.mjs --verify-golden",
8687
"guard:source-file-size": "node ./scripts/ci/check-source-file-size.mjs",
8788
"guard:source-file-size:ci": "node ./scripts/ci/verify-source-file-size.mjs --require-baseline",
8889
"guard:team-provisioning-architecture": "node ./scripts/ci/check-team-provisioning-architecture.mjs",
Lines changed: 117 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,125 @@
11
#!/usr/bin/env node
2-
import { execFile } from 'node:child_process';
32
import { createHash } from 'node:crypto';
4-
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
5-
import { tmpdir } from 'node:os';
6-
import { join, resolve } from 'node:path';
7-
import { promisify } from 'node:util';
3+
import { readFile } from 'node:fs/promises';
4+
import { dirname, resolve } from 'node:path';
5+
import { fileURLToPath } from 'node:url';
86

9-
const run = promisify(execFile);
10-
const [gitDirectoryInput, commit, admissionInput] = process.argv.slice(2);
11-
if (!gitDirectoryInput || !/^[0-9a-f]{40}$/u.test(commit ?? '') || !admissionInput) {
12-
throw new Error('usage: verify-hosted-approval-runtime-admission-authority <git-dir> <commit> <admission-json>');
7+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
8+
const goldenPath = resolve(
9+
root,
10+
'test/fixtures/hosted-approval-runtime-admission-v1.release-golden.json'
11+
);
12+
const runtimeLockPath = resolve(root, 'runtime.lock.json');
13+
const [admissionInput] = process.argv.slice(2);
14+
const golden = JSON.parse(await readFile(goldenPath, 'utf8'));
15+
const runtimeLock = JSON.parse(await readFile(runtimeLockPath, 'utf8'));
16+
17+
const exactKeys = (value, expected) =>
18+
value !== null &&
19+
typeof value === 'object' &&
20+
!Array.isArray(value) &&
21+
Object.keys(value).length === expected.length &&
22+
expected.every((key) => Object.hasOwn(value, key));
23+
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
24+
const SHA256 = /^[0-9a-f]{64}$/u;
25+
const COMMIT = /^[0-9a-f]{40}$/u;
26+
27+
function invalid(reason) {
28+
throw new Error(`hosted-approval-runtime-release-golden-invalid:${reason}`);
29+
}
30+
31+
function verifyReleaseGolden() {
32+
if (!exactKeys(golden, ['schemaVersion', 'source', 'release', 'canonicalJson', 'sha256'])) {
33+
invalid('schema');
34+
}
35+
if (
36+
golden.schemaVersion !== 2 ||
37+
golden.source !== 'agent-teams-orchestrator/hosted-approval-runtime-admission.v1'
38+
) {
39+
invalid('identity');
40+
}
41+
if (
42+
!exactKeys(golden.release, [
43+
'sourceRepository',
44+
'releaseRepository',
45+
'releaseTag',
46+
'sourceCommit',
47+
'manifestAttestation',
48+
]) ||
49+
golden.release.sourceRepository !== '777genius/agent_teams_orchestrator' ||
50+
golden.release.releaseRepository !== '777genius/agent_teams_orchestrator_binaries' ||
51+
golden.release.releaseTag !== runtimeLock.releaseTag ||
52+
golden.release.sourceCommit !== '1f9a7ffc00715e2434eb7549ac2c53880d9f6f83' ||
53+
!COMMIT.test(golden.release.sourceCommit)
54+
) {
55+
invalid('release-pin');
56+
}
57+
const attestation = golden.release.manifestAttestation;
58+
if (
59+
!exactKeys(attestation, ['kind', 'releaseId', 'assetId', 'assetName', 'assetSha256']) ||
60+
attestation.kind !== 'github-release-asset-digest/v1' ||
61+
attestation.releaseId !== 363595793 ||
62+
attestation.assetId !== 498146437 ||
63+
attestation.assetName !== `agent-teams-runtime-manifest-${runtimeLock.sourceRef}.json` ||
64+
attestation.assetSha256 !==
65+
'0b1036e1f110eefeed5b12c9637a7efa3bf35d6b09a8eb20519909e86bed2bcb' ||
66+
!SHA256.test(attestation.assetSha256)
67+
) {
68+
invalid('manifest-attestation-pin');
69+
}
70+
if (
71+
typeof golden.canonicalJson !== 'string' ||
72+
golden.sha256 !== `sha256:${sha256(golden.canonicalJson)}`
73+
) {
74+
invalid('canonical-digest');
75+
}
76+
const snapshot = JSON.parse(golden.canonicalJson);
77+
if (
78+
!exactKeys(snapshot, ['schemaVersion', 'approvalGeneration', 'authorities']) ||
79+
snapshot.schemaVersion !== 1 ||
80+
snapshot.approvalGeneration !== 1 ||
81+
!Array.isArray(snapshot.authorities) ||
82+
snapshot.authorities.length === 0
83+
) {
84+
invalid('authority-snapshot');
85+
}
86+
}
87+
88+
verifyReleaseGolden();
89+
if (admissionInput === '--verify-golden') {
90+
process.stdout.write(`${JSON.stringify({ verified: true, releaseGolden: goldenPath })}\n`);
91+
process.exit(0);
92+
}
93+
if (!admissionInput) {
94+
throw new Error('usage: verify-hosted-approval-runtime-admission-authority <admission-json>');
1395
}
1496

15-
const gitDirectory = resolve(gitDirectoryInput);
1697
const admissionPath = resolve(admissionInput);
17-
const root = await mkdtemp(join(tmpdir(), 'hosted-approval-authority-'));
18-
const authorityFiles = [
19-
'HostedApprovalRuntimeAdmission.ts',
20-
'HostedApprovalWire.ts',
21-
'ownerProof.ts',
22-
'protocol.ts',
23-
];
98+
const admissionBytes = await readFile(admissionPath);
99+
const admission = JSON.parse(admissionBytes.toString('utf8'));
100+
const match = /^approval-admission-generation_([1-9][0-9]*)_owner_[1-9][0-9]*$/u.exec(
101+
admission.admissionGeneration ?? ''
102+
);
103+
if (!match || !Array.isArray(admission.routes)) invalid('admission-shape');
104+
const snapshot = {
105+
schemaVersion: 1,
106+
approvalGeneration: Number(match[1]),
107+
authorities: [...admission.routes]
108+
.toSorted((left, right) => left.routeId.localeCompare(right.routeId))
109+
.map((route) => route.authority),
110+
};
111+
const canonicalJson = JSON.stringify(snapshot);
112+
if (canonicalJson !== golden.canonicalJson) invalid('cross-repository-authority-mismatch');
24113

25-
try {
26-
for (const file of authorityFiles) {
27-
const objectPath = `src/services/hostedControl/${file}`;
28-
const { stdout } = await run('/usr/bin/git', [
29-
`--git-dir=${gitDirectory}`,
30-
'show',
31-
`${commit}:${objectPath}`,
32-
]);
33-
await writeFile(join(root, file), stdout, { mode: 0o600 });
34-
}
35-
const admission = JSON.parse(await readFile(admissionPath, 'utf8'));
36-
const runner = `
37-
import { PrivateFileHostedApprovalRuntimeAdmissionStore } from './HostedApprovalRuntimeAdmission.ts';
38-
import { readFile } from 'node:fs/promises';
39-
void (async () => {
40-
const admission = JSON.parse(await readFile(${JSON.stringify(admissionPath)}, 'utf8'));
41-
const outer = admission.outerAuthority;
42-
const loaded = await new PrivateFileHostedApprovalRuntimeAdmissionStore(${JSON.stringify(admissionPath)}).load({
43-
deploymentId: outer.deploymentId,
44-
bootId: outer.bootId,
45-
workspaceId: outer.workspaceId,
46-
teamId: outer.teamId,
47-
restoreGeneration: outer.restoreGeneration,
48-
mountBinding: outer.mountBinding,
49-
});
50-
if (!(await loaded.isCurrent())) throw new Error('external-authority-drift');
51-
})();
52-
`;
53-
await writeFile(join(root, 'verify.ts'), runner, { mode: 0o600 });
54-
await run(process.execPath, ['--import', 'tsx', join(root, 'verify.ts')], {
55-
cwd: process.cwd(),
56-
});
57-
const source = await readFile(join(root, 'HostedApprovalRuntimeAdmission.ts'));
58-
const admissionBytes = await readFile(admissionPath);
59-
process.stdout.write(`${JSON.stringify({
114+
process.stdout.write(
115+
`${JSON.stringify({
60116
verified: true,
61-
authorityCommit: commit,
62-
authorityBlobSha256: createHash('sha256').update(source).digest('hex'),
63-
admissionSha256: createHash('sha256').update(admissionBytes).digest('hex'),
64-
approvalGeneration: admission.admissionGeneration,
65-
})}\n`);
66-
} finally {
67-
await rm(root, { recursive: true, force: true });
68-
}
117+
sourceRepository: golden.release.sourceRepository,
118+
releaseRepository: golden.release.releaseRepository,
119+
releaseTag: golden.release.releaseTag,
120+
sourceCommit: golden.release.sourceCommit,
121+
manifestAttestationSha256: golden.release.manifestAttestation.assetSha256,
122+
admissionSha256: sha256(admissionBytes),
123+
approvalGeneration: snapshot.approvalGeneration,
124+
})}\n`
125+
);

src/main/composition/team/createProductTeamProvisioning.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
observeHostedApprovalRuntimeTeamChange,
55
stopAllTeamsWithHostedApprovalRuntime,
66
} from '@main/services/team/provisioning/HostedApprovalRuntimeDesktopLifecycle';
7+
import { createHostedApprovalRuntimeLifecycleOwner } from '@main/services/team/provisioning/HostedApprovalRuntimeLifecycleOwner';
78
import { createProductOwnedTeamProvisioningService } from '@main/services/team/provisioning/HostedApprovalRuntimeProductionComposition';
89
import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder';
910

@@ -21,9 +22,13 @@ export function createProductTeamProvisioning() {
2122
composition.service,
2223
composition.hostedApprovalRuntime
2324
);
25+
const trustedLifecycleOwner = createHostedApprovalRuntimeLifecycleOwner(
26+
composition.hostedApprovalRuntime
27+
);
2428
return Object.freeze({
2529
service: composition.service,
2630
capabilities,
31+
trustedLifecycleOwner,
2732
ensureAdmissionAbsent: (teamName: string, reason: string) =>
2833
composition.hostedApprovalRuntime.ensureAbsent(teamName, reason),
2934
observeFailure: (

src/main/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2126,8 +2126,8 @@ async function initializeServices(): Promise<void> {
21262126
addNotification: (payload) => notificationManager.addTeamNotification(payload),
21272127
logger: createLogger('Feature:TeamRuntimeRecovery'),
21282128
});
2129-
teamProvisioningService.setRuntimeRecoveryFailureObserver((failure) => {
2130-
productTeamProvisioning?.observeFailure(failure, logger);
2129+
teamProvisioningService.setRuntimeRecoveryFailureObserver(async (failure) => {
2130+
await productTeamProvisioning?.observeFailure(failure, logger);
21312131
teamRuntimeRecoveryFeature?.observeLeadFailure(failure);
21322132
});
21332133
teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => {
@@ -2296,8 +2296,8 @@ async function initializeServices(): Promise<void> {
22962296
);
22972297
}
22982298
);
2299-
const teamChangeEmitter = (event: TeamChangeEvent): void => {
2300-
productTeamProvisioning?.observeTeamChange(event, logger);
2299+
const teamChangeEmitter = async (event: TeamChangeEvent): Promise<void> => {
2300+
await productTeamProvisioning?.observeTeamChange(event, logger);
23012301
notifyTeamChangeObserversSafely(
23022302
event,
23032303
[

src/main/ipc/desktopTeamFeatureCapabilitySources.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,14 @@ export function createDesktopTeamFeatureCapabilitySources(
9393
: runtime,
9494
memberLifecycle: hostedApprovalRuntime
9595
? {
96-
...memberLifecycle,
96+
getMemberSpawnStatuses: memberLifecycle.getMemberSpawnStatuses,
97+
runLiveRosterMutation: (
98+
teamName: string,
99+
operation: Parameters<typeof memberLifecycle.runLiveRosterMutation>[1]
100+
) =>
101+
hostedApprovalRuntime.beforeBindingChange(teamName, () =>
102+
memberLifecycle.runLiveRosterMutation(teamName, operation)
103+
),
97104
attachLiveRosterMember: (
98105
...args: Parameters<typeof memberLifecycle.attachLiveRosterMember>
99106
) =>
@@ -110,6 +117,16 @@ export function createDesktopTeamFeatureCapabilitySources(
110117
hostedApprovalRuntime.beforeBindingChange(args[0], () =>
111118
memberLifecycle.restartMember(...args)
112119
),
120+
retryFailedOpenCodeSecondaryLanes: (
121+
...args: Parameters<typeof memberLifecycle.retryFailedOpenCodeSecondaryLanes>
122+
) =>
123+
hostedApprovalRuntime.beforeBindingChange(args[0], () =>
124+
memberLifecycle.retryFailedOpenCodeSecondaryLanes(...args)
125+
),
126+
skipMemberForLaunch: (...args: Parameters<typeof memberLifecycle.skipMemberForLaunch>) =>
127+
hostedApprovalRuntime.beforeBindingChange(args[0], () =>
128+
memberLifecycle.skipMemberForLaunch(...args)
129+
),
113130
}
114131
: memberLifecycle,
115132
diagnostics: bindTeamDiagnosticsApi(teamProvisioningService),

src/main/services/team/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export {
2626
observeHostedApprovalRuntimeTeamChange,
2727
stopAllTeamsWithHostedApprovalRuntime,
2828
} from './provisioning/HostedApprovalRuntimeDesktopLifecycle';
29+
export {
30+
createHostedApprovalRuntimeLifecycleOwner,
31+
type HostedApprovalRuntimeLifecycleOwner,
32+
} from './provisioning/HostedApprovalRuntimeLifecycleOwner';
2933
export { createProductOwnedTeamProvisioningService } from './provisioning/HostedApprovalRuntimeProductionComposition';
3034
export type { HostedApprovalRuntimeTransitionService } from './provisioning/HostedApprovalRuntimeTransitionService';
3135
export { ReviewApplierService } from './ReviewApplierService';

src/main/services/team/provisioning/HostedApprovalRuntimeAdmissionPublisher.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createHash } from 'node:crypto';
22

3-
import { parseRuntimePermissionApprovalIngressAuthority } from '@features/team-runtime-control/contracts';
3+
import { parseRuntimePermissionApprovalIngressAuthority } from '@features/team-approvals/contracts';
44

55
import {
66
descriptorAnchoredRead,
@@ -11,7 +11,7 @@ import {
1111
import { immutableHostedApprovalRuntimeBinding } from './HostedApprovalRuntimeImmutableBinding';
1212

1313
import type { TrustedDirectoryCapability } from './HostedApprovalRuntimeDescriptorStorage';
14-
import type { RuntimePermissionApprovalIngressAuthority } from '@features/team-runtime-control/contracts';
14+
import type { RuntimePermissionApprovalIngressAuthority } from '@features/team-approvals/contracts';
1515
import type { OrchestratorSocketIdentity } from '@main/composition/hosted/hostedLifecycleOrchestratorReadiness';
1616

1717
export {

src/main/services/team/provisioning/HostedApprovalRuntimeDesktopLifecycle.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,35 +15,39 @@ export function observeHostedApprovalRuntimeFailure(
1515
runtime: HostedApprovalRuntimeTransitionService | null,
1616
failure: LeadRuntimeFailureObservation,
1717
logger: HostedApprovalRuntimeRevocationLogger
18-
): void {
19-
void runtime
20-
?.beforeFailure(failure.teamName, async () => undefined)
21-
.catch((error: unknown) =>
22-
logger.error('Hosted approval runtime failure revocation failed:', error)
23-
);
18+
): Promise<void> {
19+
if (!runtime) return Promise.resolve();
20+
return runtime
21+
.beforeFailure(failure.teamName, async () => undefined)
22+
.catch((error: unknown) => {
23+
logger.error('Hosted approval runtime failure revocation failed:', error);
24+
throw error;
25+
});
2426
}
2527

2628
export function observeHostedApprovalRuntimeTeamChange(
2729
runtime: HostedApprovalRuntimeTransitionService | null,
2830
event: TeamChangeEvent,
2931
logger: HostedApprovalRuntimeRevocationLogger
30-
): void {
32+
): Promise<void> {
3133
if (
3234
event.type !== 'process' ||
3335
(event.detail !== 'failed' &&
3436
event.detail !== 'disconnected' &&
3537
event.detail !== 'stopped' &&
3638
event.detail !== 'cancelled')
3739
) {
38-
return;
40+
return Promise.resolve();
3941
}
4042
const revoke =
4143
event.detail === 'failed'
4244
? runtime?.beforeFailure(event.teamName, async () => undefined)
4345
: runtime?.beforeOwnerLoss(event.teamName, async () => undefined);
44-
void revoke?.catch((error: unknown) =>
45-
logger.error('Hosted approval runtime owner-loss revocation failed:', error)
46-
);
46+
if (!revoke) return Promise.resolve();
47+
return revoke.catch((error: unknown) => {
48+
logger.error('Hosted approval runtime owner-loss revocation failed:', error);
49+
throw error;
50+
});
4751
}
4852

4953
export function stopAllTeamsWithHostedApprovalRuntime(
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { HostedApprovalRuntimePublication } from './HostedApprovalRuntimeAdmissionPublisher';
2+
import type { HostedApprovalRuntimeTransitionEvidence } from './HostedApprovalRuntimeAuthoritativeEvidenceAdapter';
3+
import type { HostedApprovalRuntimeTransitionService } from './HostedApprovalRuntimeTransitionService';
4+
5+
/**
6+
* Product-owned input port for the trusted launcher lifecycle owner. The caller cannot publish from
7+
* ambient state: every transition carries the authoritative lease and installed-artifact reread.
8+
*/
9+
export interface HostedApprovalRuntimeLifecycleOwner {
10+
transition(
11+
teamName: string,
12+
evidence: HostedApprovalRuntimeTransitionEvidence
13+
): Promise<HostedApprovalRuntimePublication>;
14+
}
15+
16+
export function createHostedApprovalRuntimeLifecycleOwner(
17+
runtime: HostedApprovalRuntimeTransitionService
18+
): HostedApprovalRuntimeLifecycleOwner {
19+
return Object.freeze({
20+
transition(teamName: string, evidence: HostedApprovalRuntimeTransitionEvidence) {
21+
return runtime.transition(teamName, evidence.lifecycle, evidence);
22+
},
23+
});
24+
}

0 commit comments

Comments
 (0)