diff --git a/electron.vite.config.1778078040752.mjs b/electron.vite.config.1778078040752.mjs new file mode 100644 index 00000000..95696b36 --- /dev/null +++ b/electron.vite.config.1778078040752.mjs @@ -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 +}; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0530395f..58233b05 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -255,6 +255,14 @@ import { import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; +import { + buildNativeAppManagedBootstrapSpecs, + type NativeAppManagedBootstrapSpec, +} from './bootstrap/NativeAppManagedBootstrapContextBuilder'; +import { + parseBootstrapRuntimeProofDetail, + validateBootstrapRuntimeProofEnvelope, +} from './bootstrap/BootstrapProofValidation'; import type { OpenCodeCommittedBootstrapSessionRecord, @@ -307,6 +315,10 @@ interface PersistedRuntimeMemberLike { cwd?: string; bootstrapExpectedAfter?: string; bootstrapProofToken?: string; + bootstrapRunId?: string; + bootstrapProofMode?: string; + bootstrapContextHash?: string; + bootstrapBriefingHash?: string; bootstrapRuntimeEventsPath?: string; runtimePid?: number; runtimeSessionId?: string; @@ -341,7 +353,6 @@ interface LaunchStateWriteResult { type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text'; -const BOOTSTRAP_RUNTIME_PROOF_SOURCE = 'member_briefing_tool_success'; const BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES = 256 * 1024; function sanitizeRuntimeEventFilePrefix(value: string): string { @@ -350,31 +361,6 @@ function sanitizeRuntimeEventFilePrefix(value: string): string { .toLowerCase(); } -function parseRuntimeBootstrapProofDetail(detail: unknown): Record { - 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) : {}; - } catch { - return {}; - } -} - -function getRuntimeBootstrapProofString( - event: Record, - detail: Record, - 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 = | { kind: 'success'; @@ -3927,6 +3913,7 @@ interface RuntimeBootstrapMemberSpec { description?: string; useSplitPane?: boolean; planModeRequired?: boolean; + nativeAppManagedBootstrap?: NativeAppManagedBootstrapSpec; } interface RuntimeBootstrapSpec { @@ -3961,7 +3948,8 @@ interface RuntimeBootstrapSpec { function buildDeterministicCreateBootstrapSpec( runId: string, request: TeamCreateRequest, - effectiveMembers: TeamCreateRequest['members'] + effectiveMembers: TeamCreateRequest['members'], + nativeAppManagedBootstrapByMember: ReadonlyMap = new Map() ): RuntimeBootstrapSpec { return { version: 1, @@ -4001,6 +3989,9 @@ function buildDeterministicCreateBootstrapSpec( ...(member.effort ? { effort: member.effort } : {}), ...(member.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}), ...(member.role?.trim() ? { description: member.role.trim() } : {}), + ...(nativeAppManagedBootstrapByMember.get(member.name) + ? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! } + : {}), })), launch: { continueOnPartialFailure: true, @@ -4014,7 +4005,8 @@ function buildDeterministicCreateBootstrapSpec( function buildDeterministicLaunchBootstrapSpec( runId: string, request: TeamLaunchRequest, - effectiveMembers: TeamCreateRequest['members'] + effectiveMembers: TeamCreateRequest['members'], + nativeAppManagedBootstrapByMember: ReadonlyMap = new Map() ): RuntimeBootstrapSpec { return { version: 1, @@ -4051,6 +4043,9 @@ function buildDeterministicLaunchBootstrapSpec( ...(member.role?.trim() ? { role: member.role.trim() } : {}), ...(member.workflow?.trim() ? { workflow: member.workflow.trim() } : {}), ...(member.role?.trim() ? { description: member.role.trim() } : {}), + ...(nativeAppManagedBootstrapByMember.get(member.name) + ? { nativeAppManagedBootstrap: nativeAppManagedBootstrapByMember.get(member.name)! } + : {}), })), launch: { continueOnPartialFailure: true, @@ -12041,6 +12036,14 @@ export class TeamProvisioningService { ); 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(); const leadPid = run?.child?.pid; if (typeof leadPid === 'number' && Number.isFinite(leadPid) && leadPid > 0) { @@ -12077,6 +12080,23 @@ export class TeamProvisioningService { } 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(); for (const member of configuredMembers) { @@ -12137,6 +12157,7 @@ export class TeamProvisioningService { const persistedRuntimeMember = getPersistedRuntimeMember(memberName); const liveRuntimeMember = getLiveRuntimeMember(memberName); + const spawnStatusMember = getSpawnStatusMember(memberName); const launchMember = launchSnapshot?.members[memberName]; const backendType = liveRuntimeMember?.backendType ?? @@ -12172,7 +12193,38 @@ export class TeamProvisioningService { : backendType !== 'in-process'; const historicalBootstrapConfirmed = 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; if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) { try { @@ -12188,7 +12240,7 @@ export class TeamProvisioningService { snapshotMembers[memberName] = { memberName, - alive: liveRuntimeMember?.alive === true, + alive: effectiveAlive, restartable, ...(backendType ? { backendType } : {}), ...(memberProviderId ? { providerId: memberProviderId } : {}), @@ -12201,9 +12253,7 @@ export class TeamProvisioningService { ...(runtimeModel ? { runtimeModel } : {}), ...(runtimeCwd ? { cwd: runtimeCwd } : {}), ...(typeof rssBytes === 'number' && rssBytes >= 0 ? { rssBytes } : {}), - ...(liveRuntimeMember?.livenessKind - ? { livenessKind: liveRuntimeMember.livenessKind } - : {}), + ...(effectiveLivenessKind ? { livenessKind: effectiveLivenessKind } : {}), ...(liveRuntimeMember?.pidSource ? { pidSource: liveRuntimeMember.pidSource } : {}), ...(liveRuntimeMember?.processCommand ? { processCommand: liveRuntimeMember.processCommand } @@ -12221,11 +12271,9 @@ export class TeamProvisioningService { ? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt } : {}), ...(historicalBootstrapConfirmed ? { historicalBootstrapConfirmed: true } : {}), - ...(liveRuntimeMember?.runtimeDiagnostic - ? { runtimeDiagnostic: liveRuntimeMember.runtimeDiagnostic } - : {}), - ...(liveRuntimeMember?.runtimeDiagnosticSeverity - ? { runtimeDiagnosticSeverity: liveRuntimeMember.runtimeDiagnosticSeverity } + ...(effectiveRuntimeDiagnostic ? { runtimeDiagnostic: effectiveRuntimeDiagnostic } : {}), + ...(effectiveRuntimeDiagnosticSeverity + ? { runtimeDiagnosticSeverity: effectiveRuntimeDiagnosticSeverity } : {}), ...(liveRuntimeMember?.diagnostics ? { diagnostics: liveRuntimeMember.diagnostics } : {}), updatedAt, @@ -16315,16 +16363,6 @@ export class TeamProvisioningService { emitProvisioningCheckpoint(run, 'Clearing persisted launch state'); 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 promptSize = getPromptSizeSummary(initialUserPrompt); let child: ReturnType; @@ -16335,6 +16373,52 @@ export class TeamProvisioningService { let bootstrapSpecPath: string; let bootstrapUserPromptPath: string | null = null; 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'); bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec); run.bootstrapSpecPath = bootstrapSpecPath; @@ -16366,6 +16450,11 @@ export class TeamProvisioningService { directory: provisioningEnv.anthropicApiKeyHelper.directory, }).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(() => {}); run.bootstrapSpecPath = null; await removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath).catch( @@ -16434,35 +16523,6 @@ export class TeamProvisioningService { launchIdentity, }); 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 ( run.cancelRequested || run.processKilled || @@ -17617,7 +17677,12 @@ export class TeamProvisioningService { const bootstrapSpec = buildDeterministicLaunchBootstrapSpec( runId, request, - effectiveMemberSpecs + effectiveMemberSpecs, + await buildNativeAppManagedBootstrapSpecs({ + teamName: request.teamName, + cwd: request.cwd, + members: effectiveMemberSpecs, + }) ); emitProvisioningCheckpoint(run, 'Writing deterministic bootstrap spec file'); bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec); @@ -20413,6 +20478,74 @@ export class TeamProvisioningService { 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( teamName: string ): Promise> { @@ -20620,7 +20753,12 @@ export class TeamProvisioningService { } 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 ?? {})) { const memberName = persistedMember.name?.trim() ?? ''; if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) { @@ -20739,7 +20877,6 @@ export class TeamProvisioningService { updatedAt: persistedLaunchSnapshot?.updatedAt ?? nowIso(), } : undefined; - const status = this.findTrackedMemberSpawnStatus(run, memberName) ?? adapterStatus; const shouldUseWindowsHostRows = process.platform === 'win32' && (metadata.providerId === 'opencode' || @@ -20754,6 +20891,15 @@ export class TeamProvisioningService { const memberProcessTableAvailable = shouldUseWindowsHostRows ? windowsHostProcessTableAvailable || 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({ teamName, memberName, @@ -23194,24 +23340,21 @@ export class TeamProvisioningService { boundaryMs: number; }): boolean { const { event, detail, teamName, memberName, runtimeMember, boundaryMs } = input; - if (event.type !== 'bootstrap_confirmed') { - return false; - } - if (typeof event.teamName === 'string' && event.teamName.trim() !== teamName) { - return false; - } - const source = getRuntimeBootstrapProofString(event, detail, 'source'); - if (source !== BOOTSTRAP_RUNTIME_PROOF_SOURCE) { - return false; - } - const timestamp = typeof event.timestamp === 'string' ? event.timestamp : ''; - const eventMs = Date.parse(timestamp); - 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) { + if ( + !validateBootstrapRuntimeProofEnvelope({ + event, + detail, + expected: { + teamName, + boundaryMs, + proofToken: runtimeMember?.bootstrapProofToken?.trim(), + proofMode: runtimeMember?.bootstrapProofMode?.trim(), + contextHash: runtimeMember?.bootstrapContextHash?.trim(), + briefingHash: runtimeMember?.bootstrapBriefingHash?.trim(), + runId: runtimeMember?.bootstrapRunId?.trim(), + }, + }) + ) { return false; } const eventAgentName = typeof event.agentName === 'string' ? event.agentName.trim() : ''; @@ -23245,7 +23388,7 @@ export class TeamProvisioningService { let latest: string | null = null; let latestMs = Number.NEGATIVE_INFINITY; for (const event of events) { - const detail = parseRuntimeBootstrapProofDetail(event.detail); + const detail = parseBootstrapRuntimeProofDetail(event.detail); if ( !this.isRuntimeBootstrapProofEventValid({ event, diff --git a/src/main/services/team/bootstrap/BootstrapProofValidation.ts b/src/main/services/team/bootstrap/BootstrapProofValidation.ts new file mode 100644 index 00000000..ff5804df --- /dev/null +++ b/src/main/services/team/bootstrap/BootstrapProofValidation.ts @@ -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 { + 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) + : {}; + } catch { + return {}; + } +} + +function readProofField( + event: BootstrapRuntimeProofEventLike, + detail: Record, + 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 +): 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; + 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; + 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; + 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; + 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; + 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; + expected: BootstrapRuntimeProofExpected; +}): boolean { + return validateBootstrapRuntimeProofEnvelopeDetailed(input).ok; +} diff --git a/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts b/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts new file mode 100644 index 00000000..ab3d43b5 --- /dev/null +++ b/src/main/services/team/bootstrap/NativeAppManagedBootstrapContextBuilder.ts @@ -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( + [ + '', + `Team: ${params.teamName}`, + `Member: ${params.memberName}`, + `Provider: ${params.providerId ?? 'anthropic'}`, + `Project: ${params.cwd}`, + '', + '', + briefing, + '', + '', + ].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> { + const controller = createController({ + teamName: params.teamName, + claudeDir: getClaudeBasePath(), + allowUserMessageSender: false, + }); + const result = new Map(); + 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; +} diff --git a/src/main/services/team/runtimeTeammateMode.ts b/src/main/services/team/runtimeTeammateMode.ts index da393e4c..a61af14e 100644 --- a/src/main/services/team/runtimeTeammateMode.ts +++ b/src/main/services/team/runtimeTeammateMode.ts @@ -72,10 +72,10 @@ export async function resolveDesktopTeammateModeDecision( }; } - const tmuxAvailable = await isTmuxAvailable(); + await isTmuxAvailable(); return { - injectedTeammateMode: tmuxAvailable ? 'tmux' : null, + injectedTeammateMode: null, forceProcessTeammates: true, }; } diff --git a/test/main/services/team/AnthropicRuntimeMemory.live.test.ts b/test/main/services/team/AnthropicRuntimeMemory.live.test.ts index 19a0f5cd..2cd6a97a 100644 --- a/test/main/services/team/AnthropicRuntimeMemory.live.test.ts +++ b/test/main/services/team/AnthropicRuntimeMemory.live.test.ts @@ -177,7 +177,8 @@ async function assertExecutable(filePath: string): Promise { } async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise { - 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 config: { projects: Record; @@ -203,17 +204,28 @@ async function writeTrustedClaudeConfig(configDir: string, projectPath: string): } async function removeTempDirWithRetries(dirPath: string): Promise { - const attempts = process.platform === 'win32' ? 20 : 1; + const attempts = process.platform === 'win32' ? 20 : 5; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { - await fs.rm(dirPath, { recursive: true, force: true }); + await fs.rm(dirPath, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 200, + }); return; } catch (error) { 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; } - await new Promise((resolve) => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 200)); } } } diff --git a/test/main/services/team/BootstrapProofValidation.test.ts b/test/main/services/team/BootstrapProofValidation.test.ts new file mode 100644 index 00000000..00af099d --- /dev/null +++ b/test/main/services/team/BootstrapProofValidation.test.ts @@ -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); + }); +}); diff --git a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts index a51381cd..f9c3a956 100644 --- a/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts +++ b/test/main/services/team/MemberWorkSyncClaudeStopHook.live.test.ts @@ -185,6 +185,7 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => { teamName = `member-work-sync-claude-stop-${scenario.markerSuffix}-${startedAt}`; const projectPath = path.join(tempDir, 'project'); await fs.mkdir(projectPath, { recursive: true }); + await writeTrustedClaudeConfig(tempClaudeRoot, projectPath); await fs.writeFile( path.join(projectPath, 'README.md'), '# 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 // 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. for (let attempt = 0; attempt < 6; attempt += 1) { - await fs.rm(tempDir, { recursive: true, force: true }); + await removeTempDirBestEffort(tempDir); if (attempt < 5) { await new Promise((resolve) => setTimeout(resolve, 1_000)); } } } +async function removeTempDirBestEffort(tempDir: string): Promise { + 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 { const tmpRoot = os.tmpdir(); for (let attempt = 0; attempt < 6; attempt += 1) { @@ -533,7 +549,7 @@ async function cleanupScopedClaudeStopHookLiveTempDirs(): Promise { await Promise.all( entries .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) { await new Promise((resolve) => setTimeout(resolve, 1_000)); @@ -545,6 +561,33 @@ function hasLiveAnthropicApiKey(): boolean { return Boolean(process.env.ANTHROPIC_API_KEY?.trim()); } +async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise { + 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; + 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 { const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim(); if (explicit) { diff --git a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts index 65516bf9..aacd1001 100644 --- a/test/main/services/team/MixedProviderTeamLaunch.live.test.ts +++ b/test/main/services/team/MixedProviderTeamLaunch.live.test.ts @@ -283,7 +283,8 @@ async function assertExecutable(filePath: string): Promise { } async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise { - 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 config: { projects: Record; diff --git a/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts b/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts new file mode 100644 index 00000000..7bb2abe4 --- /dev/null +++ b/test/main/services/team/NativeAppManagedBootstrapContextBuilder.test.ts @@ -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(''); + 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/); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 7cba9408..e9315099 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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>; + }; + 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>; + }; + 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 () => { allowConsoleLogs(); const teamName = 'zz-unit-bootstrap-state-stale-runtime-proof-ignored'; diff --git a/test/main/services/team/runtimeTeammateMode.test.ts b/test/main/services/team/runtimeTeammateMode.test.ts index 74313147..44faceea 100644 --- a/test/main/services/team/runtimeTeammateMode.test.ts +++ b/test/main/services/team/runtimeTeammateMode.test.ts @@ -12,7 +12,7 @@ describe('runtimeTeammateMode', () => { 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); const { resolveDesktopTeammateModeDecision } = await import('@main/services/team/runtimeTeammateMode'); @@ -20,7 +20,7 @@ describe('runtimeTeammateMode', () => { const decision = await resolveDesktopTeammateModeDecision(undefined); 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 () => { @@ -97,6 +97,6 @@ describe('runtimeTeammateMode', () => { expect(firstDecision.forceProcessTeammates).toBe(true); expect(firstDecision.injectedTeammateMode).toBeNull(); expect(secondDecision.forceProcessTeammates).toBe(true); - expect(secondDecision.injectedTeammateMode).toBe('tmux'); + expect(secondDecision.injectedTeammateMode).toBeNull(); }); });