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 { 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<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 =
|
||||
| {
|
||||
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<string, NativeAppManagedBootstrapSpec> = 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<string, NativeAppManagedBootstrapSpec> = 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<number>();
|
||||
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<string, TeamMember>();
|
||||
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<typeof spawn>;
|
||||
|
|
@ -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<Map<string, LiveTeamAgentRuntimeMetadata>> {
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
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 {
|
||||
injectedTeammateMode: tmuxAvailable ? 'tmux' : null,
|
||||
injectedTeammateMode: null,
|
||||
forceProcessTeammates: true,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,7 +177,8 @@ async function assertExecutable(filePath: 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 config: {
|
||||
projects: Record<string, { hasTrustDialogAccepted: true }>;
|
||||
|
|
@ -203,17 +204,28 @@ async function writeTrustedClaudeConfig(configDir: string, projectPath: string):
|
|||
}
|
||||
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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}`;
|
||||
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<void>
|
|||
// 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<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> {
|
||||
const tmpRoot = os.tmpdir();
|
||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||
|
|
@ -533,7 +549,7 @@ async function cleanupScopedClaudeStopHookLiveTempDirs(): Promise<void> {
|
|||
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<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 {
|
||||
const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim();
|
||||
if (explicit) {
|
||||
|
|
|
|||
|
|
@ -283,7 +283,8 @@ async function assertExecutable(filePath: 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 config: {
|
||||
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 () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'zz-unit-bootstrap-state-stale-runtime-proof-ignored';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue