feat(team): add app-managed native bootstrap
This commit is contained in:
parent
d60abd54fe
commit
1febc3448b
12 changed files with 1236 additions and 112 deletions
149
electron.vite.config.1778078040752.mjs
Normal file
149
electron.vite.config.1778078040752.mjs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
// electron.vite.config.ts
|
||||||
|
import { defineConfig } from "electron-vite";
|
||||||
|
import { sentryVitePlugin } from "@sentry/vite-plugin";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
|
var __electron_vite_injected_dirname = "/Users/belief/dev/projects/claude/claude_team";
|
||||||
|
var pkg = JSON.parse(readFileSync(resolve(__electron_vite_injected_dirname, "package.json"), "utf-8"));
|
||||||
|
var prodDeps = Object.keys(pkg.dependencies || {});
|
||||||
|
var runtimeExternalDeps = /* @__PURE__ */ new Set([
|
||||||
|
"node-pty",
|
||||||
|
"agent-teams-controller",
|
||||||
|
"fastify",
|
||||||
|
"@fastify/cors",
|
||||||
|
"@fastify/static"
|
||||||
|
]);
|
||||||
|
var bundledDeps = prodDeps.filter((d) => !runtimeExternalDeps.has(d));
|
||||||
|
function nativeModuleStub() {
|
||||||
|
const STUB_ID = "\0native-stub";
|
||||||
|
const NODE_MODULE_RE = /\.node(?:\?.*)?$/;
|
||||||
|
return {
|
||||||
|
name: "native-module-stub",
|
||||||
|
enforce: "pre",
|
||||||
|
resolveId(source) {
|
||||||
|
if (NODE_MODULE_RE.test(source)) return `${STUB_ID}:${source}`;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
load(id) {
|
||||||
|
if (id.startsWith(STUB_ID) || NODE_MODULE_RE.test(id)) return "export default {}";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var sentryPlugins = process.env.SENTRY_AUTH_TOKEN ? [
|
||||||
|
sentryVitePlugin({
|
||||||
|
org: process.env.SENTRY_ORG ?? "quant-jump-pro",
|
||||||
|
project: process.env.SENTRY_PROJECT ?? "electron",
|
||||||
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
|
release: { name: `agent-teams-ai@${pkg.version}` },
|
||||||
|
sourcemaps: {
|
||||||
|
filesToDeleteAfterUpload: ["./out/renderer/**/*.map", "./dist-electron/**/*.map"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
] : [];
|
||||||
|
var electron_vite_config_default = defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [
|
||||||
|
nativeModuleStub(),
|
||||||
|
...sentryPlugins
|
||||||
|
],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
// Inject DSN at compile time — process.env.SENTRY_DSN is NOT available
|
||||||
|
// at runtime in packaged Electron apps (only during CI build).
|
||||||
|
"process.env.SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN ?? "")
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@features": resolve(__electron_vite_injected_dirname, "src/features"),
|
||||||
|
"@main": resolve(__electron_vite_injected_dirname, "src/main"),
|
||||||
|
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
|
||||||
|
"@preload": resolve(__electron_vite_injected_dirname, "src/preload")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
externalizeDeps: {
|
||||||
|
exclude: bundledDeps
|
||||||
|
},
|
||||||
|
commonjsOptions: {
|
||||||
|
strictRequires: [/node_modules\/.*ssh2\//]
|
||||||
|
},
|
||||||
|
sourcemap: "hidden",
|
||||||
|
outDir: "dist-electron/main",
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__electron_vite_injected_dirname, "src/main/index.ts"),
|
||||||
|
"team-fs-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-fs-worker.ts"),
|
||||||
|
"task-change-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/task-change-worker.ts"),
|
||||||
|
"team-data-worker": resolve(__electron_vite_injected_dirname, "src/main/workers/team-data-worker.ts")
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
// CJS format so bundled deps can use __dirname/require.
|
||||||
|
// Use .cjs extension since package.json has "type": "module".
|
||||||
|
format: "cjs",
|
||||||
|
entryFileNames: "[name].cjs",
|
||||||
|
// Set UV_THREADPOOL_SIZE before any module code runs.
|
||||||
|
// Must be in the banner because ESM→CJS hoists imports above top-level code.
|
||||||
|
// On Windows, fs.watch({recursive:true}) occupies a UV pool thread per watcher;
|
||||||
|
// with 3+ watchers + concurrent fs/DNS/spawn, the default 4 threads deadlock.
|
||||||
|
banner: `if(!process.env.UV_THREADPOOL_SIZE){process.env.UV_THREADPOOL_SIZE='24'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@features": resolve(__electron_vite_injected_dirname, "src/features"),
|
||||||
|
"@preload": resolve(__electron_vite_injected_dirname, "src/preload"),
|
||||||
|
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
|
||||||
|
"@main": resolve(__electron_vite_injected_dirname, "src/main")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist-electron/preload",
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__electron_vite_injected_dirname, "src/preload/index.ts")
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
format: "cjs",
|
||||||
|
entryFileNames: "[name].js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ["@codemirror/language-data"],
|
||||||
|
exclude: ["@claude-teams/agent-graph"]
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
// Pass SENTRY_DSN to renderer as VITE_SENTRY_DSN (Vite replaces at compile time)
|
||||||
|
"import.meta.env.VITE_SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN ?? "")
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@features": resolve(__electron_vite_injected_dirname, "src/features"),
|
||||||
|
"@renderer": resolve(__electron_vite_injected_dirname, "src/renderer"),
|
||||||
|
"@shared": resolve(__electron_vite_injected_dirname, "src/shared"),
|
||||||
|
"@main": resolve(__electron_vite_injected_dirname, "src/main"),
|
||||||
|
"@claude-teams/agent-graph": resolve(__electron_vite_injected_dirname, "packages/agent-graph/src/index.ts")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [react(), ...sentryPlugins],
|
||||||
|
build: {
|
||||||
|
sourcemap: "hidden",
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__electron_vite_injected_dirname, "src/renderer/index.html")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export {
|
||||||
|
electron_vite_config_default as default
|
||||||
|
};
|
||||||
|
|
@ -255,6 +255,14 @@ import {
|
||||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||||
|
import {
|
||||||
|
buildNativeAppManagedBootstrapSpecs,
|
||||||
|
type NativeAppManagedBootstrapSpec,
|
||||||
|
} from './bootstrap/NativeAppManagedBootstrapContextBuilder';
|
||||||
|
import {
|
||||||
|
parseBootstrapRuntimeProofDetail,
|
||||||
|
validateBootstrapRuntimeProofEnvelope,
|
||||||
|
} from './bootstrap/BootstrapProofValidation';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
OpenCodeCommittedBootstrapSessionRecord,
|
OpenCodeCommittedBootstrapSessionRecord,
|
||||||
|
|
@ -307,6 +315,10 @@ interface PersistedRuntimeMemberLike {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
bootstrapExpectedAfter?: string;
|
bootstrapExpectedAfter?: string;
|
||||||
bootstrapProofToken?: string;
|
bootstrapProofToken?: string;
|
||||||
|
bootstrapRunId?: string;
|
||||||
|
bootstrapProofMode?: string;
|
||||||
|
bootstrapContextHash?: string;
|
||||||
|
bootstrapBriefingHash?: string;
|
||||||
bootstrapRuntimeEventsPath?: string;
|
bootstrapRuntimeEventsPath?: string;
|
||||||
runtimePid?: number;
|
runtimePid?: number;
|
||||||
runtimeSessionId?: string;
|
runtimeSessionId?: string;
|
||||||
|
|
@ -341,7 +353,6 @@ interface LaunchStateWriteResult {
|
||||||
|
|
||||||
type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text';
|
type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text';
|
||||||
|
|
||||||
const BOOTSTRAP_RUNTIME_PROOF_SOURCE = 'member_briefing_tool_success';
|
|
||||||
const BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES = 256 * 1024;
|
const BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES = 256 * 1024;
|
||||||
|
|
||||||
function sanitizeRuntimeEventFilePrefix(value: string): string {
|
function sanitizeRuntimeEventFilePrefix(value: string): string {
|
||||||
|
|
@ -350,31 +361,6 @@ function sanitizeRuntimeEventFilePrefix(value: string): string {
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRuntimeBootstrapProofDetail(detail: unknown): Record<string, unknown> {
|
|
||||||
if (typeof detail !== 'string' || detail.trim().length === 0) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(detail) as unknown;
|
|
||||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRuntimeBootstrapProofString(
|
|
||||||
event: Record<string, unknown>,
|
|
||||||
detail: Record<string, unknown>,
|
|
||||||
field: 'source' | 'bootstrapProofToken'
|
|
||||||
): string | undefined {
|
|
||||||
const direct = event[field];
|
|
||||||
if (typeof direct === 'string' && direct.trim().length > 0) {
|
|
||||||
return direct.trim();
|
|
||||||
}
|
|
||||||
const nested = detail[field];
|
|
||||||
return typeof nested === 'string' && nested.trim().length > 0 ? nested.trim() : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
type BootstrapTranscriptOutcome =
|
type BootstrapTranscriptOutcome =
|
||||||
| {
|
| {
|
||||||
kind: 'success';
|
kind: 'success';
|
||||||
|
|
@ -3927,6 +3913,7 @@ interface RuntimeBootstrapMemberSpec {
|
||||||
description?: string;
|
description?: string;
|
||||||
useSplitPane?: boolean;
|
useSplitPane?: boolean;
|
||||||
planModeRequired?: boolean;
|
planModeRequired?: boolean;
|
||||||
|
nativeAppManagedBootstrap?: NativeAppManagedBootstrapSpec;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuntimeBootstrapSpec {
|
interface RuntimeBootstrapSpec {
|
||||||
|
|
@ -3961,7 +3948,8 @@ interface RuntimeBootstrapSpec {
|
||||||
function buildDeterministicCreateBootstrapSpec(
|
function buildDeterministicCreateBootstrapSpec(
|
||||||
runId: string,
|
runId: string,
|
||||||
request: TeamCreateRequest,
|
request: TeamCreateRequest,
|
||||||
effectiveMembers: TeamCreateRequest['members']
|
effectiveMembers: TeamCreateRequest['members'],
|
||||||
|
nativeAppManagedBootstrapByMember: ReadonlyMap<string, NativeAppManagedBootstrapSpec> = new Map()
|
||||||
): RuntimeBootstrapSpec {
|
): RuntimeBootstrapSpec {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|
@ -4001,6 +3989,9 @@ function buildDeterministicCreateBootstrapSpec(
|
||||||
...(member.effort ? { effort: member.effort } : {}),
|
...(member.effort ? { effort: member.effort } : {}),
|
||||||
...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
|
...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
|
||||||
...(member.role?.trim() ? { description: member.role.trim() } : {}),
|
...(member.role?.trim() ? { description: member.role.trim() } : {}),
|
||||||
|
...(nativeAppManagedBootstrapByMember.get(member.name)
|
||||||
|
? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! }
|
||||||
|
: {}),
|
||||||
})),
|
})),
|
||||||
launch: {
|
launch: {
|
||||||
continueOnPartialFailure: true,
|
continueOnPartialFailure: true,
|
||||||
|
|
@ -4014,7 +4005,8 @@ function buildDeterministicCreateBootstrapSpec(
|
||||||
function buildDeterministicLaunchBootstrapSpec(
|
function buildDeterministicLaunchBootstrapSpec(
|
||||||
runId: string,
|
runId: string,
|
||||||
request: TeamLaunchRequest,
|
request: TeamLaunchRequest,
|
||||||
effectiveMembers: TeamCreateRequest['members']
|
effectiveMembers: TeamCreateRequest['members'],
|
||||||
|
nativeAppManagedBootstrapByMember: ReadonlyMap<string, NativeAppManagedBootstrapSpec> = new Map()
|
||||||
): RuntimeBootstrapSpec {
|
): RuntimeBootstrapSpec {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|
@ -4051,6 +4043,9 @@ function buildDeterministicLaunchBootstrapSpec(
|
||||||
...(member.role?.trim() ? { role: member.role.trim() } : {}),
|
...(member.role?.trim() ? { role: member.role.trim() } : {}),
|
||||||
...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}),
|
...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}),
|
||||||
...(member.role?.trim() ? { description: member.role.trim() } : {}),
|
...(member.role?.trim() ? { description: member.role.trim() } : {}),
|
||||||
|
...(nativeAppManagedBootstrapByMember.get(member.name)
|
||||||
|
? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! }
|
||||||
|
: {}),
|
||||||
})),
|
})),
|
||||||
launch: {
|
launch: {
|
||||||
continueOnPartialFailure: true,
|
continueOnPartialFailure: true,
|
||||||
|
|
@ -12041,6 +12036,14 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
|
|
||||||
const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
|
const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
|
||||||
|
const spawnStatusSnapshot = await this.getMemberSpawnStatuses(teamName).catch(() => null);
|
||||||
|
const activeRuntimeRunId =
|
||||||
|
run?.runId?.trim() || currentRuntimeAdapterRun?.runId?.trim() || runId?.trim() || '';
|
||||||
|
const spawnStatusRunId = spawnStatusSnapshot?.runId?.trim() ?? '';
|
||||||
|
const canUseLiveSpawnStatusRuntimeTruth =
|
||||||
|
spawnStatusSnapshot?.source === 'live' &&
|
||||||
|
activeRuntimeRunId.length > 0 &&
|
||||||
|
spawnStatusRunId === activeRuntimeRunId;
|
||||||
const runtimePids = new Set<number>();
|
const runtimePids = new Set<number>();
|
||||||
const leadPid = run?.child?.pid;
|
const leadPid = run?.child?.pid;
|
||||||
if (typeof leadPid === 'number' && Number.isFinite(leadPid) && leadPid > 0) {
|
if (typeof leadPid === 'number' && Number.isFinite(leadPid) && leadPid > 0) {
|
||||||
|
|
@ -12077,6 +12080,23 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
const getSpawnStatusMember = (memberName: string): MemberSpawnStatusEntry | undefined => {
|
||||||
|
const statuses = spawnStatusSnapshot?.statuses;
|
||||||
|
if (!statuses) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const direct = statuses[memberName];
|
||||||
|
if (direct) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
let fallback: MemberSpawnStatusEntry | undefined;
|
||||||
|
for (const [candidateName, status] of Object.entries(statuses)) {
|
||||||
|
if (matchesMemberNameOrBase(candidateName, memberName)) {
|
||||||
|
fallback = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
const candidateMembers = new Map<string, TeamMember>();
|
const candidateMembers = new Map<string, TeamMember>();
|
||||||
for (const member of configuredMembers) {
|
for (const member of configuredMembers) {
|
||||||
|
|
@ -12137,6 +12157,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const persistedRuntimeMember = getPersistedRuntimeMember(memberName);
|
const persistedRuntimeMember = getPersistedRuntimeMember(memberName);
|
||||||
const liveRuntimeMember = getLiveRuntimeMember(memberName);
|
const liveRuntimeMember = getLiveRuntimeMember(memberName);
|
||||||
|
const spawnStatusMember = getSpawnStatusMember(memberName);
|
||||||
const launchMember = launchSnapshot?.members[memberName];
|
const launchMember = launchSnapshot?.members[memberName];
|
||||||
const backendType =
|
const backendType =
|
||||||
liveRuntimeMember?.backendType ??
|
liveRuntimeMember?.backendType ??
|
||||||
|
|
@ -12172,7 +12193,38 @@ export class TeamProvisioningService {
|
||||||
: backendType !== 'in-process';
|
: backendType !== 'in-process';
|
||||||
const historicalBootstrapConfirmed =
|
const historicalBootstrapConfirmed =
|
||||||
launchMember?.bootstrapConfirmed === true ||
|
launchMember?.bootstrapConfirmed === true ||
|
||||||
launchMember?.launchState === 'confirmed_alive';
|
launchMember?.launchState === 'confirmed_alive' ||
|
||||||
|
spawnStatusMember?.bootstrapConfirmed === true ||
|
||||||
|
spawnStatusMember?.launchState === 'confirmed_alive';
|
||||||
|
const hasOpenCodeRuntimeHandle =
|
||||||
|
isOpenCodeMember &&
|
||||||
|
(typeof liveRuntimeMember?.pid === 'number' ||
|
||||||
|
typeof liveRuntimeMember?.metricsPid === 'number' ||
|
||||||
|
typeof liveRuntimeMember?.runtimeSessionId === 'string');
|
||||||
|
const confirmedOpenCodeRuntimeAlive =
|
||||||
|
isOpenCodeMember &&
|
||||||
|
canUseLiveSpawnStatusRuntimeTruth &&
|
||||||
|
historicalBootstrapConfirmed &&
|
||||||
|
hasOpenCodeRuntimeHandle &&
|
||||||
|
spawnStatusMember?.hardFailure !== true &&
|
||||||
|
spawnStatusMember?.launchState !== 'failed_to_start' &&
|
||||||
|
spawnStatusMember?.launchState !== 'runtime_pending_permission';
|
||||||
|
const effectiveAlive = liveRuntimeMember?.alive === true || confirmedOpenCodeRuntimeAlive;
|
||||||
|
const effectiveLivenessKind =
|
||||||
|
confirmedOpenCodeRuntimeAlive &&
|
||||||
|
liveRuntimeMember?.livenessKind === 'runtime_process_candidate'
|
||||||
|
? 'confirmed_bootstrap'
|
||||||
|
: liveRuntimeMember?.livenessKind;
|
||||||
|
const effectiveRuntimeDiagnostic =
|
||||||
|
confirmedOpenCodeRuntimeAlive &&
|
||||||
|
liveRuntimeMember?.livenessKind === 'runtime_process_candidate'
|
||||||
|
? 'OpenCode bootstrap confirmed; runtime host/session evidence present.'
|
||||||
|
: liveRuntimeMember?.runtimeDiagnostic;
|
||||||
|
const effectiveRuntimeDiagnosticSeverity =
|
||||||
|
confirmedOpenCodeRuntimeAlive &&
|
||||||
|
liveRuntimeMember?.livenessKind === 'runtime_process_candidate'
|
||||||
|
? 'info'
|
||||||
|
: liveRuntimeMember?.runtimeDiagnosticSeverity;
|
||||||
let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined;
|
let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined;
|
||||||
if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) {
|
if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -12188,7 +12240,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
snapshotMembers[memberName] = {
|
snapshotMembers[memberName] = {
|
||||||
memberName,
|
memberName,
|
||||||
alive: liveRuntimeMember?.alive === true,
|
alive: effectiveAlive,
|
||||||
restartable,
|
restartable,
|
||||||
...(backendType ? { backendType } : {}),
|
...(backendType ? { backendType } : {}),
|
||||||
...(memberProviderId ? { providerId: memberProviderId } : {}),
|
...(memberProviderId ? { providerId: memberProviderId } : {}),
|
||||||
|
|
@ -12201,9 +12253,7 @@ export class TeamProvisioningService {
|
||||||
...(runtimeModel ? { runtimeModel } : {}),
|
...(runtimeModel ? { runtimeModel } : {}),
|
||||||
...(runtimeCwd ? { cwd: runtimeCwd } : {}),
|
...(runtimeCwd ? { cwd: runtimeCwd } : {}),
|
||||||
...(typeof rssBytes === 'number' && rssBytes >= 0 ? { rssBytes } : {}),
|
...(typeof rssBytes === 'number' && rssBytes >= 0 ? { rssBytes } : {}),
|
||||||
...(liveRuntimeMember?.livenessKind
|
...(effectiveLivenessKind ? { livenessKind: effectiveLivenessKind } : {}),
|
||||||
? { livenessKind: liveRuntimeMember.livenessKind }
|
|
||||||
: {}),
|
|
||||||
...(liveRuntimeMember?.pidSource ? { pidSource: liveRuntimeMember.pidSource } : {}),
|
...(liveRuntimeMember?.pidSource ? { pidSource: liveRuntimeMember.pidSource } : {}),
|
||||||
...(liveRuntimeMember?.processCommand
|
...(liveRuntimeMember?.processCommand
|
||||||
? { processCommand: liveRuntimeMember.processCommand }
|
? { processCommand: liveRuntimeMember.processCommand }
|
||||||
|
|
@ -12221,11 +12271,9 @@ export class TeamProvisioningService {
|
||||||
? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt }
|
? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt }
|
||||||
: {}),
|
: {}),
|
||||||
...(historicalBootstrapConfirmed ? { historicalBootstrapConfirmed: true } : {}),
|
...(historicalBootstrapConfirmed ? { historicalBootstrapConfirmed: true } : {}),
|
||||||
...(liveRuntimeMember?.runtimeDiagnostic
|
...(effectiveRuntimeDiagnostic ? { runtimeDiagnostic: effectiveRuntimeDiagnostic } : {}),
|
||||||
? { runtimeDiagnostic: liveRuntimeMember.runtimeDiagnostic }
|
...(effectiveRuntimeDiagnosticSeverity
|
||||||
: {}),
|
? { runtimeDiagnosticSeverity: effectiveRuntimeDiagnosticSeverity }
|
||||||
...(liveRuntimeMember?.runtimeDiagnosticSeverity
|
|
||||||
? { runtimeDiagnosticSeverity: liveRuntimeMember.runtimeDiagnosticSeverity }
|
|
||||||
: {}),
|
: {}),
|
||||||
...(liveRuntimeMember?.diagnostics ? { diagnostics: liveRuntimeMember.diagnostics } : {}),
|
...(liveRuntimeMember?.diagnostics ? { diagnostics: liveRuntimeMember.diagnostics } : {}),
|
||||||
updatedAt,
|
updatedAt,
|
||||||
|
|
@ -16315,16 +16363,6 @@ export class TeamProvisioningService {
|
||||||
emitProvisioningCheckpoint(run, 'Clearing persisted launch state');
|
emitProvisioningCheckpoint(run, 'Clearing persisted launch state');
|
||||||
await this.clearPersistedLaunchState(request.teamName);
|
await this.clearPersistedLaunchState(request.teamName);
|
||||||
|
|
||||||
emitProvisioningCheckpoint(
|
|
||||||
run,
|
|
||||||
'Building deterministic create bootstrap spec',
|
|
||||||
`expectedMembers=${effectiveMemberSpecs.length}`
|
|
||||||
);
|
|
||||||
const bootstrapSpec = buildDeterministicCreateBootstrapSpec(
|
|
||||||
runId,
|
|
||||||
request,
|
|
||||||
effectiveMemberSpecs
|
|
||||||
);
|
|
||||||
const initialUserPrompt = request.prompt?.trim() ?? '';
|
const initialUserPrompt = request.prompt?.trim() ?? '';
|
||||||
const promptSize = getPromptSizeSummary(initialUserPrompt);
|
const promptSize = getPromptSizeSummary(initialUserPrompt);
|
||||||
let child: ReturnType<typeof spawn>;
|
let child: ReturnType<typeof spawn>;
|
||||||
|
|
@ -16335,6 +16373,52 @@ export class TeamProvisioningService {
|
||||||
let bootstrapSpecPath: string;
|
let bootstrapSpecPath: string;
|
||||||
let bootstrapUserPromptPath: string | null = null;
|
let bootstrapUserPromptPath: string | null = null;
|
||||||
try {
|
try {
|
||||||
|
// Pre-save our meta files before native app-managed briefing generation.
|
||||||
|
// member_briefing intentionally reads canonical team metadata/inboxes, so
|
||||||
|
// createTeam must materialize those files before building the bootstrap spec.
|
||||||
|
emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn');
|
||||||
|
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
||||||
|
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
||||||
|
await fs.promises.mkdir(teamDir, { recursive: true });
|
||||||
|
await fs.promises.mkdir(tasksDir, { recursive: true });
|
||||||
|
await this.teamMetaStore.writeMeta(request.teamName, {
|
||||||
|
displayName: request.displayName,
|
||||||
|
description: request.description,
|
||||||
|
color: request.color,
|
||||||
|
cwd: request.cwd,
|
||||||
|
prompt: request.prompt,
|
||||||
|
providerId: request.providerId,
|
||||||
|
providerBackendId: request.providerBackendId,
|
||||||
|
model: request.model,
|
||||||
|
effort: request.effort,
|
||||||
|
fastMode: request.fastMode,
|
||||||
|
skipPermissions: request.skipPermissions,
|
||||||
|
worktree: request.worktree,
|
||||||
|
extraCliArgs: request.extraCliArgs,
|
||||||
|
limitContext: request.limitContext,
|
||||||
|
launchIdentity,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
const membersToWrite = this.buildMembersMetaWritePayload(allEffectiveMemberSpecs);
|
||||||
|
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
|
||||||
|
providerBackendId: request.providerBackendId,
|
||||||
|
});
|
||||||
|
emitProvisioningCheckpoint(
|
||||||
|
run,
|
||||||
|
'Building deterministic create bootstrap spec',
|
||||||
|
`expectedMembers=${effectiveMemberSpecs.length}`
|
||||||
|
);
|
||||||
|
const nativeAppManagedBootstrapByMember = await buildNativeAppManagedBootstrapSpecs({
|
||||||
|
teamName: request.teamName,
|
||||||
|
cwd: request.cwd,
|
||||||
|
members: effectiveMemberSpecs,
|
||||||
|
});
|
||||||
|
const bootstrapSpec = buildDeterministicCreateBootstrapSpec(
|
||||||
|
runId,
|
||||||
|
request,
|
||||||
|
effectiveMemberSpecs,
|
||||||
|
nativeAppManagedBootstrapByMember
|
||||||
|
);
|
||||||
emitProvisioningCheckpoint(run, 'Writing deterministic bootstrap spec file');
|
emitProvisioningCheckpoint(run, 'Writing deterministic bootstrap spec file');
|
||||||
bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec);
|
bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec);
|
||||||
run.bootstrapSpecPath = bootstrapSpecPath;
|
run.bootstrapSpecPath = bootstrapSpecPath;
|
||||||
|
|
@ -16366,6 +16450,11 @@ export class TeamProvisioningService {
|
||||||
directory: provisioningEnv.anthropicApiKeyHelper.directory,
|
directory: provisioningEnv.anthropicApiKeyHelper.directory,
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
await this.teamMetaStore.deleteMeta(request.teamName).catch(() => {});
|
||||||
|
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
||||||
|
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
||||||
|
await fs.promises.rm(teamDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
await fs.promises.rm(tasksDir, { recursive: true, force: true }).catch(() => {});
|
||||||
await removeDeterministicBootstrapSpecFile(run.bootstrapSpecPath).catch(() => {});
|
await removeDeterministicBootstrapSpecFile(run.bootstrapSpecPath).catch(() => {});
|
||||||
run.bootstrapSpecPath = null;
|
run.bootstrapSpecPath = null;
|
||||||
await removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath).catch(
|
await removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath).catch(
|
||||||
|
|
@ -16434,35 +16523,6 @@ export class TeamProvisioningService {
|
||||||
launchIdentity,
|
launchIdentity,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
// Pre-save our meta files before spawn — CLI doesn't touch these.
|
|
||||||
// If provisioning fails before TeamCreate, user can retry without re-entering config.
|
|
||||||
emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn');
|
|
||||||
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
|
||||||
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
|
||||||
await fs.promises.mkdir(teamDir, { recursive: true });
|
|
||||||
await fs.promises.mkdir(tasksDir, { recursive: true });
|
|
||||||
await this.teamMetaStore.writeMeta(request.teamName, {
|
|
||||||
displayName: request.displayName,
|
|
||||||
description: request.description,
|
|
||||||
color: request.color,
|
|
||||||
cwd: request.cwd,
|
|
||||||
prompt: request.prompt,
|
|
||||||
providerId: request.providerId,
|
|
||||||
providerBackendId: request.providerBackendId,
|
|
||||||
model: request.model,
|
|
||||||
effort: request.effort,
|
|
||||||
fastMode: request.fastMode,
|
|
||||||
skipPermissions: request.skipPermissions,
|
|
||||||
worktree: request.worktree,
|
|
||||||
extraCliArgs: request.extraCliArgs,
|
|
||||||
limitContext: request.limitContext,
|
|
||||||
launchIdentity,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
});
|
|
||||||
const membersToWrite = this.buildMembersMetaWritePayload(allEffectiveMemberSpecs);
|
|
||||||
await this.membersMetaStore.writeMembers(request.teamName, membersToWrite, {
|
|
||||||
providerBackendId: request.providerBackendId,
|
|
||||||
});
|
|
||||||
if (
|
if (
|
||||||
run.cancelRequested ||
|
run.cancelRequested ||
|
||||||
run.processKilled ||
|
run.processKilled ||
|
||||||
|
|
@ -17617,7 +17677,12 @@ export class TeamProvisioningService {
|
||||||
const bootstrapSpec = buildDeterministicLaunchBootstrapSpec(
|
const bootstrapSpec = buildDeterministicLaunchBootstrapSpec(
|
||||||
runId,
|
runId,
|
||||||
request,
|
request,
|
||||||
effectiveMemberSpecs
|
effectiveMemberSpecs,
|
||||||
|
await buildNativeAppManagedBootstrapSpecs({
|
||||||
|
teamName: request.teamName,
|
||||||
|
cwd: request.cwd,
|
||||||
|
members: effectiveMemberSpecs,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
emitProvisioningCheckpoint(run, 'Writing deterministic bootstrap spec file');
|
emitProvisioningCheckpoint(run, 'Writing deterministic bootstrap spec file');
|
||||||
bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec);
|
bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec);
|
||||||
|
|
@ -20413,6 +20478,74 @@ export class TeamProvisioningService {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildLaunchMemberSpawnStatus(
|
||||||
|
member: PersistedTeamLaunchMemberState | undefined,
|
||||||
|
runtimeModel?: string
|
||||||
|
): MemberSpawnStatusEntry | undefined {
|
||||||
|
if (!member) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: member.hardFailure
|
||||||
|
? 'error'
|
||||||
|
: member.bootstrapConfirmed || member.launchState === 'confirmed_alive'
|
||||||
|
? 'online'
|
||||||
|
: member.agentToolAccepted
|
||||||
|
? 'waiting'
|
||||||
|
: 'spawning',
|
||||||
|
launchState: member.launchState,
|
||||||
|
...(member.hardFailureReason ? { hardFailureReason: member.hardFailureReason } : {}),
|
||||||
|
...(member.pendingPermissionRequestIds?.length
|
||||||
|
? { pendingPermissionRequestIds: member.pendingPermissionRequestIds }
|
||||||
|
: {}),
|
||||||
|
agentToolAccepted: member.agentToolAccepted,
|
||||||
|
runtimeAlive: member.runtimeAlive,
|
||||||
|
bootstrapConfirmed: member.bootstrapConfirmed,
|
||||||
|
hardFailure: member.hardFailure,
|
||||||
|
...(runtimeModel ? { runtimeModel } : {}),
|
||||||
|
...(member.livenessKind ? { livenessKind: member.livenessKind } : {}),
|
||||||
|
...(member.runtimeDiagnostic ? { runtimeDiagnostic: member.runtimeDiagnostic } : {}),
|
||||||
|
...(member.runtimeDiagnosticSeverity
|
||||||
|
? { runtimeDiagnosticSeverity: member.runtimeDiagnosticSeverity }
|
||||||
|
: {}),
|
||||||
|
...(member.bootstrapStalled ? { bootstrapStalled: true } : {}),
|
||||||
|
...(member.firstSpawnAcceptedAt ? { firstSpawnAcceptedAt: member.firstSpawnAcceptedAt } : {}),
|
||||||
|
...(member.lastHeartbeatAt ? { lastHeartbeatAt: member.lastHeartbeatAt } : {}),
|
||||||
|
updatedAt: member.lastEvaluatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldPreferCurrentLaunchMemberStatus(
|
||||||
|
trackedStatus: MemberSpawnStatusEntry | undefined,
|
||||||
|
launchStatus: MemberSpawnStatusEntry | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!launchStatus?.bootstrapConfirmed && launchStatus?.launchState !== 'confirmed_alive') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!trackedStatus) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
trackedStatus.hardFailure !== true &&
|
||||||
|
trackedStatus.launchState !== 'failed_to_start' &&
|
||||||
|
trackedStatus.launchState !== 'runtime_pending_permission'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isLaunchMemberStatusRelevantToRuntimeRun(
|
||||||
|
member: PersistedTeamLaunchMemberState | undefined,
|
||||||
|
activeRuntimeRunId: string
|
||||||
|
): boolean {
|
||||||
|
if (!member || activeRuntimeRunId.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const memberRuntimeRunId = member.runtimeRunId?.trim() ?? '';
|
||||||
|
if (member.providerId === 'opencode') {
|
||||||
|
return memberRuntimeRunId.length > 0 && memberRuntimeRunId === activeRuntimeRunId;
|
||||||
|
}
|
||||||
|
return memberRuntimeRunId.length === 0 || memberRuntimeRunId === activeRuntimeRunId;
|
||||||
|
}
|
||||||
|
|
||||||
private async getLiveTeamAgentRuntimeMetadata(
|
private async getLiveTeamAgentRuntimeMetadata(
|
||||||
teamName: string
|
teamName: string
|
||||||
): Promise<Map<string, LiveTeamAgentRuntimeMetadata>> {
|
): Promise<Map<string, LiveTeamAgentRuntimeMetadata>> {
|
||||||
|
|
@ -20620,7 +20753,12 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentRuntimeAdapterRun = this.runtimeAdapterRunByTeam.get(teamName);
|
const currentRuntimeAdapterRun = this.runtimeAdapterRunByTeam.get(teamName);
|
||||||
const persistedLaunchSnapshot = await this.launchStateStore.read(teamName).catch(() => null);
|
const persistedLaunchSnapshot = choosePreferredLaunchSnapshot(
|
||||||
|
await readBootstrapLaunchSnapshot(teamName).catch(() => null),
|
||||||
|
await this.launchStateStore.read(teamName).catch(() => null)
|
||||||
|
);
|
||||||
|
const activeRuntimeRunId =
|
||||||
|
run?.runId?.trim() || currentRuntimeAdapterRun?.runId?.trim() || runId?.trim() || '';
|
||||||
for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) {
|
for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) {
|
||||||
const memberName = persistedMember.name?.trim() ?? '';
|
const memberName = persistedMember.name?.trim() ?? '';
|
||||||
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) {
|
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) {
|
||||||
|
|
@ -20739,7 +20877,6 @@ export class TeamProvisioningService {
|
||||||
updatedAt: persistedLaunchSnapshot?.updatedAt ?? nowIso(),
|
updatedAt: persistedLaunchSnapshot?.updatedAt ?? nowIso(),
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const status = this.findTrackedMemberSpawnStatus(run, memberName) ?? adapterStatus;
|
|
||||||
const shouldUseWindowsHostRows =
|
const shouldUseWindowsHostRows =
|
||||||
process.platform === 'win32' &&
|
process.platform === 'win32' &&
|
||||||
(metadata.providerId === 'opencode' ||
|
(metadata.providerId === 'opencode' ||
|
||||||
|
|
@ -20754,6 +20891,15 @@ export class TeamProvisioningService {
|
||||||
const memberProcessTableAvailable = shouldUseWindowsHostRows
|
const memberProcessTableAvailable = shouldUseWindowsHostRows
|
||||||
? windowsHostProcessTableAvailable || processTableAvailable
|
? windowsHostProcessTableAvailable || processTableAvailable
|
||||||
: processTableAvailable;
|
: processTableAvailable;
|
||||||
|
const trackedStatus = this.findTrackedMemberSpawnStatus(run, memberName);
|
||||||
|
const launchStatus =
|
||||||
|
this.isLaunchMemberStatusRelevantToRuntimeRun(launchMember, activeRuntimeRunId) &&
|
||||||
|
launchMember
|
||||||
|
? this.buildLaunchMemberSpawnStatus(launchMember, metadata.model)
|
||||||
|
: undefined;
|
||||||
|
const status = this.shouldPreferCurrentLaunchMemberStatus(trackedStatus, launchStatus)
|
||||||
|
? launchStatus
|
||||||
|
: (trackedStatus ?? adapterStatus ?? launchStatus);
|
||||||
const resolved = resolveTeamMemberRuntimeLiveness({
|
const resolved = resolveTeamMemberRuntimeLiveness({
|
||||||
teamName,
|
teamName,
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -23194,24 +23340,21 @@ export class TeamProvisioningService {
|
||||||
boundaryMs: number;
|
boundaryMs: number;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const { event, detail, teamName, memberName, runtimeMember, boundaryMs } = input;
|
const { event, detail, teamName, memberName, runtimeMember, boundaryMs } = input;
|
||||||
if (event.type !== 'bootstrap_confirmed') {
|
if (
|
||||||
return false;
|
!validateBootstrapRuntimeProofEnvelope({
|
||||||
}
|
event,
|
||||||
if (typeof event.teamName === 'string' && event.teamName.trim() !== teamName) {
|
detail,
|
||||||
return false;
|
expected: {
|
||||||
}
|
teamName,
|
||||||
const source = getRuntimeBootstrapProofString(event, detail, 'source');
|
boundaryMs,
|
||||||
if (source !== BOOTSTRAP_RUNTIME_PROOF_SOURCE) {
|
proofToken: runtimeMember?.bootstrapProofToken?.trim(),
|
||||||
return false;
|
proofMode: runtimeMember?.bootstrapProofMode?.trim(),
|
||||||
}
|
contextHash: runtimeMember?.bootstrapContextHash?.trim(),
|
||||||
const timestamp = typeof event.timestamp === 'string' ? event.timestamp : '';
|
briefingHash: runtimeMember?.bootstrapBriefingHash?.trim(),
|
||||||
const eventMs = Date.parse(timestamp);
|
runId: runtimeMember?.bootstrapRunId?.trim(),
|
||||||
if (Number.isFinite(boundaryMs) && (!Number.isFinite(eventMs) || eventMs < boundaryMs)) {
|
},
|
||||||
return false;
|
})
|
||||||
}
|
) {
|
||||||
const expectedToken = runtimeMember?.bootstrapProofToken?.trim();
|
|
||||||
const eventToken = getRuntimeBootstrapProofString(event, detail, 'bootstrapProofToken');
|
|
||||||
if (expectedToken && eventToken !== expectedToken) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const eventAgentName = typeof event.agentName === 'string' ? event.agentName.trim() : '';
|
const eventAgentName = typeof event.agentName === 'string' ? event.agentName.trim() : '';
|
||||||
|
|
@ -23245,7 +23388,7 @@ export class TeamProvisioningService {
|
||||||
let latest: string | null = null;
|
let latest: string | null = null;
|
||||||
let latestMs = Number.NEGATIVE_INFINITY;
|
let latestMs = Number.NEGATIVE_INFINITY;
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
const detail = parseRuntimeBootstrapProofDetail(event.detail);
|
const detail = parseBootstrapRuntimeProofDetail(event.detail);
|
||||||
if (
|
if (
|
||||||
!this.isRuntimeBootstrapProofEventValid({
|
!this.isRuntimeBootstrapProofEventValid({
|
||||||
event,
|
event,
|
||||||
|
|
|
||||||
225
src/main/services/team/bootstrap/BootstrapProofValidation.ts
Normal file
225
src/main/services/team/bootstrap/BootstrapProofValidation.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
export const LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE = 'member_briefing_tool_success';
|
||||||
|
export const NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE =
|
||||||
|
'native_app_managed_bootstrap_private_turn';
|
||||||
|
|
||||||
|
type BootstrapProofField =
|
||||||
|
| 'source'
|
||||||
|
| 'bootstrapProofToken'
|
||||||
|
| 'contextHash'
|
||||||
|
| 'briefingHash'
|
||||||
|
| 'runId';
|
||||||
|
|
||||||
|
export type BootstrapProofSource =
|
||||||
|
| typeof LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE
|
||||||
|
| typeof NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE;
|
||||||
|
|
||||||
|
export type BootstrapProofValidationFailureReason =
|
||||||
|
| 'wrong_event_type'
|
||||||
|
| 'wrong_team'
|
||||||
|
| 'stale_timestamp'
|
||||||
|
| 'unsupported_source'
|
||||||
|
| 'missing_team'
|
||||||
|
| 'missing_token'
|
||||||
|
| 'token_mismatch'
|
||||||
|
| 'missing_run_id'
|
||||||
|
| 'run_id_mismatch'
|
||||||
|
| 'missing_hash'
|
||||||
|
| 'hash_mismatch'
|
||||||
|
| 'wrong_proof_mode';
|
||||||
|
|
||||||
|
export type BootstrapProofValidationResult =
|
||||||
|
| { ok: true; source: BootstrapProofSource }
|
||||||
|
| { ok: false; reason: BootstrapProofValidationFailureReason; diagnostic: string };
|
||||||
|
|
||||||
|
export interface BootstrapRuntimeProofEventLike {
|
||||||
|
type?: unknown;
|
||||||
|
timestamp?: unknown;
|
||||||
|
teamName?: unknown;
|
||||||
|
source?: unknown;
|
||||||
|
bootstrapProofToken?: unknown;
|
||||||
|
contextHash?: unknown;
|
||||||
|
briefingHash?: unknown;
|
||||||
|
runId?: unknown;
|
||||||
|
detail?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapRuntimeProofExpected {
|
||||||
|
teamName: string;
|
||||||
|
boundaryMs: number;
|
||||||
|
proofToken?: string;
|
||||||
|
proofMode?: string;
|
||||||
|
contextHash?: string;
|
||||||
|
briefingHash?: string;
|
||||||
|
runId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBootstrapRuntimeProofDetail(detail: unknown): Record<string, unknown> {
|
||||||
|
if (typeof detail !== 'string' || detail.trim().length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(detail) as unknown;
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProofField(
|
||||||
|
event: BootstrapRuntimeProofEventLike,
|
||||||
|
detail: Record<string, unknown>,
|
||||||
|
field: BootstrapProofField
|
||||||
|
): string | undefined {
|
||||||
|
const direct = event[field];
|
||||||
|
if (typeof direct === 'string' && direct.trim().length > 0) {
|
||||||
|
return direct.trim();
|
||||||
|
}
|
||||||
|
const nested = detail[field];
|
||||||
|
return typeof nested === 'string' && nested.trim().length > 0 ? nested.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBootstrapProofSource(
|
||||||
|
event: BootstrapRuntimeProofEventLike,
|
||||||
|
detail: Record<string, unknown>
|
||||||
|
): BootstrapProofSource | undefined {
|
||||||
|
const source = readProofField(event, detail, 'source');
|
||||||
|
return source === LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE ||
|
||||||
|
source === NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE
|
||||||
|
? source
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reject(
|
||||||
|
reason: BootstrapProofValidationFailureReason,
|
||||||
|
diagnostic: string
|
||||||
|
): BootstrapProofValidationResult {
|
||||||
|
return { ok: false, reason, diagnostic };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateExpectedProofToken(input: {
|
||||||
|
event: BootstrapRuntimeProofEventLike;
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
expected: BootstrapRuntimeProofExpected;
|
||||||
|
}): BootstrapProofValidationResult | null {
|
||||||
|
if (!input.expected.proofToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const eventToken = readProofField(input.event, input.detail, 'bootstrapProofToken');
|
||||||
|
if (!eventToken) {
|
||||||
|
return reject('missing_token', 'Bootstrap proof token is missing');
|
||||||
|
}
|
||||||
|
if (eventToken !== input.expected.proofToken) {
|
||||||
|
return reject('token_mismatch', 'Bootstrap proof token does not match the current attempt');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLegacyMemberBriefingProof(input: {
|
||||||
|
event: BootstrapRuntimeProofEventLike;
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
expected: BootstrapRuntimeProofExpected;
|
||||||
|
}): BootstrapProofValidationResult {
|
||||||
|
const tokenFailure = validateExpectedProofToken(input);
|
||||||
|
return tokenFailure ?? { ok: true, source: LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNativeAppManagedProof(input: {
|
||||||
|
event: BootstrapRuntimeProofEventLike;
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
expected: BootstrapRuntimeProofExpected;
|
||||||
|
}): BootstrapProofValidationResult {
|
||||||
|
const eventTeamName = typeof input.event.teamName === 'string' ? input.event.teamName.trim() : '';
|
||||||
|
if (!eventTeamName) {
|
||||||
|
return reject('missing_team', 'Native app-managed bootstrap proof is missing teamName');
|
||||||
|
}
|
||||||
|
if (eventTeamName !== input.expected.teamName) {
|
||||||
|
return reject('wrong_team', 'Native app-managed bootstrap proof teamName does not match');
|
||||||
|
}
|
||||||
|
if (input.expected.proofMode !== 'native_app_managed_context') {
|
||||||
|
return reject('wrong_proof_mode', 'Native app-managed bootstrap proof mode is not expected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenFailure = validateExpectedProofToken(input);
|
||||||
|
if (tokenFailure) {
|
||||||
|
return tokenFailure;
|
||||||
|
}
|
||||||
|
if (!input.expected.proofToken) {
|
||||||
|
return reject('missing_token', 'Native app-managed bootstrap expected proof token is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const runId = readProofField(input.event, input.detail, 'runId');
|
||||||
|
if (!input.expected.runId || !runId) {
|
||||||
|
return reject('missing_run_id', 'Native app-managed bootstrap runId is missing');
|
||||||
|
}
|
||||||
|
if (runId !== input.expected.runId) {
|
||||||
|
return reject('run_id_mismatch', 'Native app-managed bootstrap runId does not match');
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextHash = readProofField(input.event, input.detail, 'contextHash');
|
||||||
|
const briefingHash = readProofField(input.event, input.detail, 'briefingHash');
|
||||||
|
if (
|
||||||
|
!input.expected.contextHash ||
|
||||||
|
!input.expected.briefingHash ||
|
||||||
|
!contextHash ||
|
||||||
|
!briefingHash
|
||||||
|
) {
|
||||||
|
return reject('missing_hash', 'Native app-managed bootstrap proof hash metadata is missing');
|
||||||
|
}
|
||||||
|
if (contextHash !== input.expected.contextHash || briefingHash !== input.expected.briefingHash) {
|
||||||
|
return reject('hash_mismatch', 'Native app-managed bootstrap proof hashes do not match');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, source: NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE };
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOTSTRAP_PROOF_VALIDATORS: Record<
|
||||||
|
BootstrapProofSource,
|
||||||
|
(input: {
|
||||||
|
event: BootstrapRuntimeProofEventLike;
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
expected: BootstrapRuntimeProofExpected;
|
||||||
|
}) => BootstrapProofValidationResult
|
||||||
|
> = {
|
||||||
|
[LEGACY_MEMBER_BRIEFING_BOOTSTRAP_PROOF_SOURCE]: validateLegacyMemberBriefingProof,
|
||||||
|
[NATIVE_APP_MANAGED_BOOTSTRAP_PROOF_SOURCE]: validateNativeAppManagedProof,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function validateBootstrapRuntimeProofEnvelopeDetailed(input: {
|
||||||
|
event: BootstrapRuntimeProofEventLike;
|
||||||
|
detail?: Record<string, unknown>;
|
||||||
|
expected: BootstrapRuntimeProofExpected;
|
||||||
|
}): BootstrapProofValidationResult {
|
||||||
|
const { event, expected } = input;
|
||||||
|
const detail = input.detail ?? parseBootstrapRuntimeProofDetail(event.detail);
|
||||||
|
if (event.type !== 'bootstrap_confirmed') {
|
||||||
|
return reject('wrong_event_type', 'Runtime event is not bootstrap_confirmed');
|
||||||
|
}
|
||||||
|
if (typeof event.teamName === 'string' && event.teamName.trim() !== expected.teamName) {
|
||||||
|
return reject('wrong_team', 'Bootstrap proof teamName does not match');
|
||||||
|
}
|
||||||
|
const timestamp = typeof event.timestamp === 'string' ? event.timestamp : '';
|
||||||
|
const eventMs = Date.parse(timestamp);
|
||||||
|
if (
|
||||||
|
Number.isFinite(expected.boundaryMs) &&
|
||||||
|
(!Number.isFinite(eventMs) || eventMs < expected.boundaryMs)
|
||||||
|
) {
|
||||||
|
return reject('stale_timestamp', 'Bootstrap proof timestamp is older than the current attempt');
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = getBootstrapProofSource(event, detail);
|
||||||
|
if (!source) {
|
||||||
|
return reject('unsupported_source', 'Bootstrap proof source is missing or unsupported');
|
||||||
|
}
|
||||||
|
|
||||||
|
return BOOTSTRAP_PROOF_VALIDATORS[source]({ event, detail, expected });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateBootstrapRuntimeProofEnvelope(input: {
|
||||||
|
event: BootstrapRuntimeProofEventLike;
|
||||||
|
detail?: Record<string, unknown>;
|
||||||
|
expected: BootstrapRuntimeProofExpected;
|
||||||
|
}): boolean {
|
||||||
|
return validateBootstrapRuntimeProofEnvelopeDetailed(input).ok;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||||
|
import type { TeamCreateRequest, TeamProviderId } from '@shared/types';
|
||||||
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
|
|
||||||
|
const { createController } = agentTeamsControllerModule;
|
||||||
|
|
||||||
|
export interface NativeAppManagedBootstrapSpec {
|
||||||
|
schemaVersion: 1;
|
||||||
|
mode: 'startup_context_file';
|
||||||
|
contextText: string;
|
||||||
|
contextHash: string;
|
||||||
|
briefingHash: string;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_NATIVE_BOOTSTRAP_BRIEFING_CHARS = 18_000;
|
||||||
|
const MAX_NATIVE_BOOTSTRAP_CONTEXT_CHARS = 24_000;
|
||||||
|
const MAX_NATIVE_BOOTSTRAP_TOTAL_CONTEXT_CHARS = 96_000;
|
||||||
|
|
||||||
|
export function isNativeAppManagedBootstrapProvider(providerId?: TeamProviderId): boolean {
|
||||||
|
return providerId == null || providerId === 'anthropic' || providerId === 'codex';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizeNativeBootstrapContextText(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.replace(/\r/g, '\n')
|
||||||
|
.replace(/[ \t]+\n/g, '\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashNativeBootstrapText(input: string): string {
|
||||||
|
return createHash('sha256').update(canonicalizeNativeBootstrapContextText(input)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactNativeBootstrapContextText(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(/sk-ant-[A-Za-z0-9_-]+/g, '[REDACTED_ANTHROPIC_API_KEY]')
|
||||||
|
.replace(/sk-[A-Za-z0-9_-]{20,}/g, '[REDACTED_API_KEY]')
|
||||||
|
.replace(/(ANTHROPIC_API_KEY|OPENAI_API_KEY|CODEX_API_KEY)=\S+/g, '$1=[REDACTED]')
|
||||||
|
.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [REDACTED]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundText(input: string, maxChars: number): string {
|
||||||
|
const canonical = canonicalizeNativeBootstrapContextText(input);
|
||||||
|
if (canonical.length <= maxChars) {
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
return `${canonical.slice(0, maxChars)}\n[truncated native bootstrap context]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContextText(params: {
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
cwd: string;
|
||||||
|
briefing: string;
|
||||||
|
}): string {
|
||||||
|
const briefing = boundText(
|
||||||
|
redactNativeBootstrapContextText(params.briefing),
|
||||||
|
MAX_NATIVE_BOOTSTRAP_BRIEFING_CHARS
|
||||||
|
);
|
||||||
|
return boundText(
|
||||||
|
[
|
||||||
|
'<agent_teams_native_bootstrap_context>',
|
||||||
|
`Team: ${params.teamName}`,
|
||||||
|
`Member: ${params.memberName}`,
|
||||||
|
`Provider: ${params.providerId ?? 'anthropic'}`,
|
||||||
|
`Project: ${params.cwd}`,
|
||||||
|
'',
|
||||||
|
'<member_briefing_context_data>',
|
||||||
|
briefing,
|
||||||
|
'</member_briefing_context_data>',
|
||||||
|
'</agent_teams_native_bootstrap_context>',
|
||||||
|
].join('\n'),
|
||||||
|
MAX_NATIVE_BOOTSTRAP_CONTEXT_CHARS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalNativeMemberBriefing(params: {
|
||||||
|
teamName: string;
|
||||||
|
cwd: string;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
member: TeamCreateRequest['members'][number];
|
||||||
|
unavailableReason: string;
|
||||||
|
}): string {
|
||||||
|
const member = params.member;
|
||||||
|
return [
|
||||||
|
`You are ${member.name}, a teammate in team ${params.teamName}.`,
|
||||||
|
`Provider: ${params.providerId ?? 'anthropic'}`,
|
||||||
|
`Project: ${member.cwd?.trim() || params.cwd}`,
|
||||||
|
member.role ? `Role: ${member.role}` : '',
|
||||||
|
member.workflow ? `Workflow: ${member.workflow}` : '',
|
||||||
|
member.model ? `Model: ${member.model}` : '',
|
||||||
|
member.effort ? `Effort: ${member.effort}` : '',
|
||||||
|
'',
|
||||||
|
'The app loaded this startup context from the current team launch request because canonical member_briefing metadata was not available yet.',
|
||||||
|
`Diagnostic: ${params.unavailableReason}`,
|
||||||
|
'',
|
||||||
|
'Startup rules:',
|
||||||
|
'- Treat yourself as unavailable until the private bootstrap turn succeeds.',
|
||||||
|
'- Do not call member_briefing for launch readiness in this flow.',
|
||||||
|
'- Use Agent Teams messaging/task tools only after launch readiness is confirmed.',
|
||||||
|
]
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildNativeAppManagedBootstrapSpecs(params: {
|
||||||
|
teamName: string;
|
||||||
|
cwd: string;
|
||||||
|
members: TeamCreateRequest['members'];
|
||||||
|
}): Promise<Map<string, NativeAppManagedBootstrapSpec>> {
|
||||||
|
const controller = createController({
|
||||||
|
teamName: params.teamName,
|
||||||
|
claudeDir: getClaudeBasePath(),
|
||||||
|
allowUserMessageSender: false,
|
||||||
|
});
|
||||||
|
const result = new Map<string, NativeAppManagedBootstrapSpec>();
|
||||||
|
let totalContextChars = 0;
|
||||||
|
|
||||||
|
for (const member of params.members) {
|
||||||
|
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? 'anthropic';
|
||||||
|
if (!isNativeAppManagedBootstrapProvider(providerId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let briefing: string;
|
||||||
|
try {
|
||||||
|
briefing = String(
|
||||||
|
await controller.tasks.memberBriefing(member.name, {
|
||||||
|
runtimeProvider: 'native',
|
||||||
|
includeActiveProcesses: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!message.includes('Member not found in team metadata or inboxes')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// In createTeam, the orchestrator's canonical config/inboxes may not
|
||||||
|
// exist until after the lead process runs. Fail-closed would break team
|
||||||
|
// creation, so use bounded request metadata while keeping readiness tied
|
||||||
|
// to the private bootstrap proof, never to this context load.
|
||||||
|
briefing = buildLocalNativeMemberBriefing({
|
||||||
|
teamName: params.teamName,
|
||||||
|
cwd: params.cwd,
|
||||||
|
providerId,
|
||||||
|
member,
|
||||||
|
unavailableReason: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const boundedBriefing = boundText(
|
||||||
|
redactNativeBootstrapContextText(briefing),
|
||||||
|
MAX_NATIVE_BOOTSTRAP_BRIEFING_CHARS
|
||||||
|
);
|
||||||
|
if (!boundedBriefing) {
|
||||||
|
throw new Error(`Native app-managed member briefing was empty for ${member.name}`);
|
||||||
|
}
|
||||||
|
const contextText = buildContextText({
|
||||||
|
teamName: params.teamName,
|
||||||
|
memberName: member.name,
|
||||||
|
providerId,
|
||||||
|
cwd: member.cwd?.trim() || params.cwd,
|
||||||
|
briefing: boundedBriefing,
|
||||||
|
});
|
||||||
|
totalContextChars += contextText.length;
|
||||||
|
if (totalContextChars > MAX_NATIVE_BOOTSTRAP_TOTAL_CONTEXT_CHARS) {
|
||||||
|
throw new Error('Native app-managed bootstrap context exceeds aggregate size budget');
|
||||||
|
}
|
||||||
|
|
||||||
|
result.set(member.name, {
|
||||||
|
schemaVersion: 1,
|
||||||
|
mode: 'startup_context_file',
|
||||||
|
contextText,
|
||||||
|
contextHash: hashNativeBootstrapText(contextText),
|
||||||
|
briefingHash: hashNativeBootstrapText(boundedBriefing),
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -72,10 +72,10 @@ export async function resolveDesktopTeammateModeDecision(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmuxAvailable = await isTmuxAvailable();
|
await isTmuxAvailable();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
injectedTeammateMode: tmuxAvailable ? 'tmux' : null,
|
injectedTeammateMode: null,
|
||||||
forceProcessTeammates: true,
|
forceProcessTeammates: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,8 @@ async function assertExecutable(filePath: string): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
||||||
const normalizedProjectPath = path.normalize(projectPath).replace(/\\/g, '/');
|
const realProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||||
|
const normalizedProjectPath = path.normalize(realProjectPath).replace(/\\/g, '/');
|
||||||
const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20);
|
const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20);
|
||||||
const config: {
|
const config: {
|
||||||
projects: Record<string, { hasTrustDialogAccepted: true }>;
|
projects: Record<string, { hasTrustDialogAccepted: true }>;
|
||||||
|
|
@ -203,17 +204,28 @@ async function writeTrustedClaudeConfig(configDir: string, projectPath: string):
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeTempDirWithRetries(dirPath: string): Promise<void> {
|
async function removeTempDirWithRetries(dirPath: string): Promise<void> {
|
||||||
const attempts = process.platform === 'win32' ? 20 : 1;
|
const attempts = process.platform === 'win32' ? 20 : 5;
|
||||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
await fs.rm(dirPath, { recursive: true, force: true });
|
await fs.rm(dirPath, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
maxRetries: 3,
|
||||||
|
retryDelay: 200,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = (error as NodeJS.ErrnoException).code;
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
if ((code !== 'EBUSY' && code !== 'EPERM') || attempt === attempts) {
|
if (code === 'ENOENT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(code !== 'EBUSY' && code !== 'EPERM' && code !== 'ENOTEMPTY') ||
|
||||||
|
attempt === attempts
|
||||||
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
test/main/services/team/BootstrapProofValidation.test.ts
Normal file
73
test/main/services/team/BootstrapProofValidation.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseBootstrapRuntimeProofDetail,
|
||||||
|
validateBootstrapRuntimeProofEnvelope,
|
||||||
|
validateBootstrapRuntimeProofEnvelopeDetailed,
|
||||||
|
} from '../../../../src/main/services/team/bootstrap/BootstrapProofValidation';
|
||||||
|
|
||||||
|
describe('BootstrapProofValidation', () => {
|
||||||
|
const expected = {
|
||||||
|
teamName: 'native-proof-team',
|
||||||
|
boundaryMs: Date.parse('2026-05-01T10:00:00.000Z'),
|
||||||
|
proofToken: 'proof-token',
|
||||||
|
proofMode: 'native_app_managed_context',
|
||||||
|
runId: 'run-native-proof',
|
||||||
|
contextHash: 'a'.repeat(64),
|
||||||
|
briefingHash: 'b'.repeat(64),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts native app-managed proof only when team, token, run and hashes match', () => {
|
||||||
|
expect(
|
||||||
|
validateBootstrapRuntimeProofEnvelope({
|
||||||
|
event: {
|
||||||
|
type: 'bootstrap_confirmed',
|
||||||
|
timestamp: '2026-05-01T10:00:01.000Z',
|
||||||
|
teamName: expected.teamName,
|
||||||
|
source: 'native_app_managed_bootstrap_private_turn',
|
||||||
|
bootstrapProofToken: expected.proofToken,
|
||||||
|
runId: expected.runId,
|
||||||
|
contextHash: expected.contextHash,
|
||||||
|
briefingHash: expected.briefingHash,
|
||||||
|
},
|
||||||
|
expected,
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects native app-managed proof without explicit team binding', () => {
|
||||||
|
const result = validateBootstrapRuntimeProofEnvelopeDetailed({
|
||||||
|
event: {
|
||||||
|
type: 'bootstrap_confirmed',
|
||||||
|
timestamp: '2026-05-01T10:00:01.000Z',
|
||||||
|
source: 'native_app_managed_bootstrap_private_turn',
|
||||||
|
bootstrapProofToken: expected.proofToken,
|
||||||
|
runId: expected.runId,
|
||||||
|
contextHash: expected.contextHash,
|
||||||
|
briefingHash: expected.briefingHash,
|
||||||
|
},
|
||||||
|
expected,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, reason: 'missing_team' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps legacy member_briefing proof compatible with missing teamName', () => {
|
||||||
|
expect(
|
||||||
|
validateBootstrapRuntimeProofEnvelope({
|
||||||
|
event: {
|
||||||
|
type: 'bootstrap_confirmed',
|
||||||
|
timestamp: '2026-05-01T10:00:01.000Z',
|
||||||
|
source: 'member_briefing_tool_success',
|
||||||
|
bootstrapProofToken: expected.proofToken,
|
||||||
|
},
|
||||||
|
detail: parseBootstrapRuntimeProofDetail(''),
|
||||||
|
expected: {
|
||||||
|
teamName: expected.teamName,
|
||||||
|
boundaryMs: expected.boundaryMs,
|
||||||
|
proofToken: expected.proofToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -185,6 +185,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
|
||||||
teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`;
|
teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`;
|
||||||
const projectPath = path.join(tempDir, 'project');
|
const projectPath = path.join(tempDir, 'project');
|
||||||
await fs.mkdir(projectPath, { recursive: true });
|
await fs.mkdir(projectPath, { recursive: true });
|
||||||
|
await writeTrustedClaudeConfig(tempClaudeRoot, projectPath);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(projectPath, 'README.md'),
|
path.join(projectPath, 'README.md'),
|
||||||
'# Member work sync Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n',
|
'# Member work sync Claude Stop hook live e2e\n\nKeep this project intentionally tiny.\n',
|
||||||
|
|
@ -514,13 +515,28 @@ async function removeTempDirAfterLateShellWrites(tempDir: string): Promise<void>
|
||||||
// Claude Code can leave child shells that write ~/.zsh_history just after stopTeam cleanup.
|
// Claude Code can leave child shells that write ~/.zsh_history just after stopTeam cleanup.
|
||||||
// Bounded repeated passes keep live tests from leaving tiny recreated HOME directories behind.
|
// Bounded repeated passes keep live tests from leaving tiny recreated HOME directories behind.
|
||||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await removeTempDirBestEffort(tempDir);
|
||||||
if (attempt < 5) {
|
if (attempt < 5) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeTempDirBestEffort(tempDir: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null;
|
||||||
|
if (code === 'ENOENT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Live Claude processes can briefly recreate files under the temp HOME while
|
||||||
|
// the test harness is tearing down. The repeated outer cleanup loop handles
|
||||||
|
// those late writes, so cleanup must not turn an already-finished live e2e
|
||||||
|
// assertion into a false failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function cleanupScopedClaudeStopHookLiveTempDirs(): Promise<void> {
|
async function cleanupScopedClaudeStopHookLiveTempDirs(): Promise<void> {
|
||||||
const tmpRoot = os.tmpdir();
|
const tmpRoot = os.tmpdir();
|
||||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||||
|
|
@ -533,7 +549,7 @@ async function cleanupScopedClaudeStopHookLiveTempDirs(): Promise<void> {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
entries
|
entries
|
||||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith('member-work-sync-claude-stop-live-'))
|
.filter((entry) => entry.isDirectory() && entry.name.startsWith('member-work-sync-claude-stop-live-'))
|
||||||
.map((entry) => fs.rm(path.join(tmpRoot, entry.name), { recursive: true, force: true }))
|
.map((entry) => removeTempDirBestEffort(path.join(tmpRoot, entry.name)))
|
||||||
);
|
);
|
||||||
if (attempt < 5) {
|
if (attempt < 5) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||||
|
|
@ -545,6 +561,33 @@ function hasLiveAnthropicApiKey(): boolean {
|
||||||
return Boolean(process.env.ANTHROPIC_API_KEY?.trim());
|
return Boolean(process.env.ANTHROPIC_API_KEY?.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
||||||
|
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||||
|
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
||||||
|
const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20);
|
||||||
|
const config: {
|
||||||
|
projects: Record<string, { hasTrustDialogAccepted: true }>;
|
||||||
|
customApiKeyResponses?: { approved: string[]; rejected: string[] };
|
||||||
|
} = {
|
||||||
|
projects: {
|
||||||
|
[normalizedProjectPath]: {
|
||||||
|
hasTrustDialogAccepted: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (approvedApiKeySuffix) {
|
||||||
|
config.customApiKeyResponses = {
|
||||||
|
approved: [approvedApiKeySuffix],
|
||||||
|
rejected: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(configDir, '.claude.json'),
|
||||||
|
`${JSON.stringify(config, null, 2)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveConnectedClaudeHome(previousHome: string | undefined): string {
|
function resolveConnectedClaudeHome(previousHome: string | undefined): string {
|
||||||
const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim();
|
const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim();
|
||||||
if (explicit) {
|
if (explicit) {
|
||||||
|
|
|
||||||
|
|
@ -283,7 +283,8 @@ async function assertExecutable(filePath: string): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
||||||
const normalizedProjectPath = path.normalize(projectPath).replace(/\\/g, '/');
|
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||||
|
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
||||||
const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20);
|
const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20);
|
||||||
const config: {
|
const config: {
|
||||||
projects: Record<string, { hasTrustDialogAccepted: true }>;
|
projects: Record<string, { hasTrustDialogAccepted: true }>;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { mkdtemp, rm } from 'fs/promises';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildNativeAppManagedBootstrapSpecs,
|
||||||
|
hashNativeBootstrapText,
|
||||||
|
} from '../../../../src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder';
|
||||||
|
import { TeamMembersMetaStore } from '../../../../src/main/services/team/TeamMembersMetaStore';
|
||||||
|
import { TeamMetaStore } from '../../../../src/main/services/team/TeamMetaStore';
|
||||||
|
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||||
|
|
||||||
|
describe('NativeAppManagedBootstrapContextBuilder', () => {
|
||||||
|
let tempClaudeRoot = '';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tempClaudeRoot = await mkdtemp(join(tmpdir(), 'native-bootstrap-builder-'));
|
||||||
|
setClaudeBasePathOverride(tempClaudeRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
await rm(tempClaudeRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonical hash normalizes line endings and trailing whitespace', () => {
|
||||||
|
expect(hashNativeBootstrapText('line 1\r\nline 2 \n')).toBe(
|
||||||
|
hashNativeBootstrapText('line 1\nline 2')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds bounded redacted context for native providers and skips non-native providers', async () => {
|
||||||
|
await new TeamMetaStore().writeMeta('native-ready-team', {
|
||||||
|
cwd: '/tmp/workspace',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
await new TeamMembersMetaStore().writeMembers('native-ready-team', [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
role: 'Reviewer ANTHROPIC_API_KEY=sk-ant-secret',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
providerId: 'codex',
|
||||||
|
role: 'Developer Bearer secret-token',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'zoe',
|
||||||
|
providerId: 'gemini',
|
||||||
|
role: 'Gemini member',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tom',
|
||||||
|
providerId: 'opencode',
|
||||||
|
role: 'OpenCode member',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const specs = await buildNativeAppManagedBootstrapSpecs({
|
||||||
|
teamName: 'native-ready-team',
|
||||||
|
cwd: '/tmp/workspace',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
role: 'Reviewer ANTHROPIC_API_KEY=sk-ant-secret',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
providerId: 'codex',
|
||||||
|
role: 'Developer Bearer secret-token',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'zoe',
|
||||||
|
providerId: 'gemini',
|
||||||
|
role: 'Gemini member',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tom',
|
||||||
|
providerId: 'opencode',
|
||||||
|
role: 'OpenCode member',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect([...specs.keys()].sort()).toEqual(['alice', 'bob']);
|
||||||
|
const alice = specs.get('alice');
|
||||||
|
const bob = specs.get('bob');
|
||||||
|
expect(alice?.contextText).toContain('<agent_teams_native_bootstrap_context>');
|
||||||
|
expect(alice?.contextText).not.toContain('sk-ant-secret');
|
||||||
|
expect(alice?.contextText).toContain('ANTHROPIC_API_KEY=[REDACTED]');
|
||||||
|
expect(bob?.contextText).not.toContain('Bearer secret-token');
|
||||||
|
expect(bob?.contextText).toContain('Bearer [REDACTED]');
|
||||||
|
expect(alice?.contextHash).toBe(hashNativeBootstrapText(alice?.contextText ?? ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when aggregate native context budget is exceeded', async () => {
|
||||||
|
const hugeRole = 'x'.repeat(40_000);
|
||||||
|
await new TeamMetaStore().writeMeta('large-native-team', {
|
||||||
|
cwd: '/tmp/workspace',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
await new TeamMembersMetaStore().writeMembers(
|
||||||
|
'large-native-team',
|
||||||
|
Array.from({ length: 8 }, (_, index) => ({
|
||||||
|
name: `member-${index}`,
|
||||||
|
providerId: 'anthropic' as const,
|
||||||
|
role: hugeRole,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
buildNativeAppManagedBootstrapSpecs({
|
||||||
|
teamName: 'large-native-team',
|
||||||
|
cwd: '/tmp/workspace',
|
||||||
|
members: Array.from({ length: 8 }, (_, index) => ({
|
||||||
|
name: `member-${index}`,
|
||||||
|
providerId: 'anthropic' as const,
|
||||||
|
role: hugeRole,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/aggregate size budget/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12779,6 +12779,167 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('heals terminal bootstrap-state failures when native app-managed proof matches token and hashes', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'zz-unit-bootstrap-state-native-runtime-proof-heals';
|
||||||
|
const leadSessionId = 'lead-session';
|
||||||
|
const projectPath = '/Users/test/proj';
|
||||||
|
const acceptedAt = new Date(Date.now() - 90_000).toISOString();
|
||||||
|
const proofAt = new Date(Date.now() - 60_000).toISOString();
|
||||||
|
const failureAt = new Date(Date.now() - 30_000).toISOString();
|
||||||
|
const proofToken = 'proof-token-jack-native';
|
||||||
|
const bootstrapRunId = 'run-native-proof';
|
||||||
|
const contextHash = 'a'.repeat(64);
|
||||||
|
const briefingHash = 'b'.repeat(64);
|
||||||
|
const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl');
|
||||||
|
|
||||||
|
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
|
||||||
|
const configPath = path.join(tempTeamsBase, teamName, 'config.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||||
|
members: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
config.members = config.members.map((member) =>
|
||||||
|
member.name === 'jack'
|
||||||
|
? {
|
||||||
|
...member,
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
bootstrapExpectedAfter: acceptedAt,
|
||||||
|
bootstrapProofToken: proofToken,
|
||||||
|
bootstrapRunId,
|
||||||
|
bootstrapProofMode: 'native_app_managed_context',
|
||||||
|
bootstrapContextHash: contextHash,
|
||||||
|
bootstrapBriefingHash: briefingHash,
|
||||||
|
bootstrapRuntimeEventsPath: runtimeEventsPath,
|
||||||
|
}
|
||||||
|
: member
|
||||||
|
);
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
|
||||||
|
writeBootstrapState(
|
||||||
|
teamName,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'jack',
|
||||||
|
status: 'failed',
|
||||||
|
lastAttemptAt: Date.parse(acceptedAt),
|
||||||
|
lastObservedAt: Date.parse(failureAt),
|
||||||
|
failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
failureAt
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
runtimeEventsPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
type: 'bootstrap_confirmed',
|
||||||
|
timestamp: proofAt,
|
||||||
|
pid: 1234,
|
||||||
|
teamName,
|
||||||
|
agentName: 'jack',
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
runId: bootstrapRunId,
|
||||||
|
source: 'native_app_managed_bootstrap_private_turn',
|
||||||
|
bootstrapProofToken: proofToken,
|
||||||
|
contextHash,
|
||||||
|
briefingHash,
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.teamLaunchState).toBe('clean_success');
|
||||||
|
expect(result.statuses.jack).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
hardFailure: false,
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not heal terminal bootstrap-state failures from native app-managed proof with mismatched hashes', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'zz-unit-bootstrap-state-native-runtime-proof-hash-mismatch';
|
||||||
|
const leadSessionId = 'lead-session';
|
||||||
|
const projectPath = '/Users/test/proj';
|
||||||
|
const acceptedAt = new Date(Date.now() - 90_000).toISOString();
|
||||||
|
const proofAt = new Date(Date.now() - 60_000).toISOString();
|
||||||
|
const failureAt = new Date(Date.now() - 30_000).toISOString();
|
||||||
|
const proofToken = 'proof-token-jack-native';
|
||||||
|
const bootstrapRunId = 'run-native-proof';
|
||||||
|
const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl');
|
||||||
|
|
||||||
|
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
|
||||||
|
const configPath = path.join(tempTeamsBase, teamName, 'config.json');
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||||
|
members: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
config.members = config.members.map((member) =>
|
||||||
|
member.name === 'jack'
|
||||||
|
? {
|
||||||
|
...member,
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
bootstrapExpectedAfter: acceptedAt,
|
||||||
|
bootstrapProofToken: proofToken,
|
||||||
|
bootstrapRunId,
|
||||||
|
bootstrapProofMode: 'native_app_managed_context',
|
||||||
|
bootstrapContextHash: 'a'.repeat(64),
|
||||||
|
bootstrapBriefingHash: 'b'.repeat(64),
|
||||||
|
bootstrapRuntimeEventsPath: runtimeEventsPath,
|
||||||
|
}
|
||||||
|
: member
|
||||||
|
);
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
|
||||||
|
writeBootstrapState(
|
||||||
|
teamName,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'jack',
|
||||||
|
status: 'failed',
|
||||||
|
lastAttemptAt: Date.parse(acceptedAt),
|
||||||
|
lastObservedAt: Date.parse(failureAt),
|
||||||
|
failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
failureAt
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
runtimeEventsPath,
|
||||||
|
`${JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
type: 'bootstrap_confirmed',
|
||||||
|
timestamp: proofAt,
|
||||||
|
pid: 1234,
|
||||||
|
teamName,
|
||||||
|
agentName: 'jack',
|
||||||
|
agentId: `jack@${teamName}`,
|
||||||
|
runId: bootstrapRunId,
|
||||||
|
source: 'native_app_managed_bootstrap_private_turn',
|
||||||
|
bootstrapProofToken: proofToken,
|
||||||
|
contextHash: 'c'.repeat(64),
|
||||||
|
briefingHash: 'b'.repeat(64),
|
||||||
|
})}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.teamLaunchState).toBe('partial_failure');
|
||||||
|
expect(result.statuses.jack).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
runtimeAlive: false,
|
||||||
|
hardFailure: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('does not heal bootstrap-state failures from stale runtime proof before spawn acceptance', async () => {
|
it('does not heal bootstrap-state failures from stale runtime proof before spawn acceptance', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
const teamName = 'zz-unit-bootstrap-state-stale-runtime-proof-ignored';
|
const teamName = 'zz-unit-bootstrap-state-stale-runtime-proof-ignored';
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ describe('runtimeTeammateMode', () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('enables process teammates in auto mode when tmux runtime is ready', async () => {
|
it('does not inject tmux mode in default desktop launch when tmux runtime is ready', async () => {
|
||||||
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true);
|
||||||
const { resolveDesktopTeammateModeDecision } =
|
const { resolveDesktopTeammateModeDecision } =
|
||||||
await import('@main/services/team/runtimeTeammateMode');
|
await import('@main/services/team/runtimeTeammateMode');
|
||||||
|
|
@ -20,7 +20,7 @@ describe('runtimeTeammateMode', () => {
|
||||||
const decision = await resolveDesktopTeammateModeDecision(undefined);
|
const decision = await resolveDesktopTeammateModeDecision(undefined);
|
||||||
|
|
||||||
expect(decision.forceProcessTeammates).toBe(true);
|
expect(decision.forceProcessTeammates).toBe(true);
|
||||||
expect(decision.injectedTeammateMode).toBe('tmux');
|
expect(decision.injectedTeammateMode).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses native process teammates when tmux runtime is not ready', async () => {
|
it('uses native process teammates when tmux runtime is not ready', async () => {
|
||||||
|
|
@ -97,6 +97,6 @@ describe('runtimeTeammateMode', () => {
|
||||||
expect(firstDecision.forceProcessTeammates).toBe(true);
|
expect(firstDecision.forceProcessTeammates).toBe(true);
|
||||||
expect(firstDecision.injectedTeammateMode).toBeNull();
|
expect(firstDecision.injectedTeammateMode).toBeNull();
|
||||||
expect(secondDecision.forceProcessTeammates).toBe(true);
|
expect(secondDecision.forceProcessTeammates).toBe(true);
|
||||||
expect(secondDecision.injectedTeammateMode).toBe('tmux');
|
expect(secondDecision.injectedTeammateMode).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue