@tiangong-ai/cli 0.0.23 → 0.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/README.md +44 -32
- package/dist/io.d.ts +3 -0
- package/dist/io.js.map +1 -1
- package/dist/main.js +1 -0
- package/dist/main.js.map +1 -1
- package/dist/research/orchestration.js +7 -0
- package/dist/research/orchestration.js.map +1 -1
- package/dist/research/setup-command.d.ts +3 -0
- package/dist/research/setup-command.js +487 -0
- package/dist/research/setup-command.js.map +1 -0
- package/dist/research/workspace/broker.js +39 -4
- package/dist/research/workspace/broker.js.map +1 -1
- package/dist/research/workspace/capabilities.js +64 -0
- package/dist/research/workspace/capabilities.js.map +1 -1
- package/dist/research/workspace/constants.d.ts +3 -2
- package/dist/research/workspace/constants.js +19 -0
- package/dist/research/workspace/constants.js.map +1 -1
- package/dist/research/workspace/context.js +26 -1
- package/dist/research/workspace/context.js.map +1 -1
- package/dist/research/workspace/external-skills.d.ts +20 -1
- package/dist/research/workspace/external-skills.js +143 -2
- package/dist/research/workspace/external-skills.js.map +1 -1
- package/dist/research/workspace/runtime.js +1 -1
- package/dist/research/workspace/runtime.js.map +1 -1
- package/dist/research/workspace/setup-catalog.d.ts +175 -0
- package/dist/research/workspace/setup-catalog.js +588 -0
- package/dist/research/workspace/setup-catalog.js.map +1 -0
- package/dist/research/workspace/setup-wizard.d.ts +24 -0
- package/dist/research/workspace/setup-wizard.js +476 -0
- package/dist/research/workspace/setup-wizard.js.map +1 -0
- package/dist/research/workspace/setup.d.ts +468 -0
- package/dist/research/workspace/setup.js +3000 -0
- package/dist/research/workspace/setup.js.map +1 -0
- package/dist/research/workspace/storage.js +7 -0
- package/dist/research/workspace/storage.js.map +1 -1
- package/dist/research/workspace/types.d.ts +13 -1
- package/dist/research/workspace/workspace.js +56 -7
- package/dist/research/workspace/workspace.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,3000 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { chmod, link, lstat, open, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { hostname, homedir, platform } from "node:os";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
7
|
+
import { CliError } from "../../errors.js";
|
|
8
|
+
import { loadCapabilityDeclarations } from "./capabilities.js";
|
|
9
|
+
import { inspectResearchContext } from "./context.js";
|
|
10
|
+
import { inspectCapabilityCredentialEnvironment, setCapabilityCredentialFromEnvironment, } from "./credentials.js";
|
|
11
|
+
import { configureExternalSkillProfile, configureTiangongSciCapability, doctorExternalCapabilities, EXTERNAL_SKILL_CONTEXT_PROFILE, EXTERNAL_SKILL_MEDIA_PROFILE, EXTERNAL_SKILL_PROFILE, } from "./external-skills.js";
|
|
12
|
+
import { appendJournalEvent } from "./journal.js";
|
|
13
|
+
import { configuredResearchSecrets, isSensitiveEnvironmentName, sanitizeResearchRecord, sanitizeResearchText, } from "./sanitization.js";
|
|
14
|
+
import { inspectResearchSetupCatalog, RESEARCH_SETUP_CREDENTIALS, RESEARCH_SETUP_INSTALLER, RESEARCH_SETUP_SETTINGS, RESEARCH_SETUP_SKILLS, resolveSetupSkills, setupSkill, setupSource, setupTargetRoot, } from "./setup-catalog.js";
|
|
15
|
+
import { acquireFileLock, canonicalJson, ensureDirectory, fileSize, hashRegularTree, isObject, pathExists, readJsonFile, sha256File, sha256Text, workspacePaths, writeJsonAtomic, writeTextAtomic, } from "./storage.js";
|
|
16
|
+
import { packageVersion } from "./constants.js";
|
|
17
|
+
import { doctorResearchWorkspace, initializeResearchWorkspace, loadWorkspaceConfig, } from "./workspace.js";
|
|
18
|
+
const BRAVE_PROFILE_SKILLS = {
|
|
19
|
+
none: [],
|
|
20
|
+
[EXTERNAL_SKILL_PROFILE]: ["brave.web-search", "brave.news-search"],
|
|
21
|
+
[EXTERNAL_SKILL_CONTEXT_PROFILE]: ["brave.web-search", "brave.news-search", "brave.llm-context"],
|
|
22
|
+
[EXTERNAL_SKILL_MEDIA_PROFILE]: [
|
|
23
|
+
"brave.web-search",
|
|
24
|
+
"brave.news-search",
|
|
25
|
+
"brave.llm-context",
|
|
26
|
+
"brave.images-search",
|
|
27
|
+
"brave.videos-search",
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
const ADAPTER_ENV_KEY = "TIANGONG_RESEARCH_ADAPTER_CREDENTIALS_JSON";
|
|
31
|
+
const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
|
|
32
|
+
export async function createResearchSetupPlan(input) {
|
|
33
|
+
const root = requireAbsoluteWorkspace(input.workspace);
|
|
34
|
+
await assertWorkspaceDirectory(root);
|
|
35
|
+
const scope = input.scope ?? "project";
|
|
36
|
+
const agents = normalizeAgents(input.agents ?? ["codex"]);
|
|
37
|
+
if (!input.confirmNetworkDownloads &&
|
|
38
|
+
input.skillIds.length + BRAVE_PROFILE_SKILLS[input.evidenceProfile].length) {
|
|
39
|
+
throw setupError({
|
|
40
|
+
code: "RESEARCH_SETUP_CONFIRMATION_REQUIRED",
|
|
41
|
+
step: "plan",
|
|
42
|
+
reason: "Pinned source and npm downloads were not explicitly confirmed.",
|
|
43
|
+
minimumAction: "Review the catalog and pass the explicit network-download confirmation.",
|
|
44
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
45
|
+
exitCode: 2,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (scope === "global" && !input.confirmGlobalMutation) {
|
|
49
|
+
throw setupError({
|
|
50
|
+
code: "RESEARCH_SETUP_CONFIRMATION_REQUIRED",
|
|
51
|
+
step: "plan",
|
|
52
|
+
reason: "Global Skill installation requires a separate explicit confirmation.",
|
|
53
|
+
minimumAction: "Prefer project scope, or explicitly confirm global mutation.",
|
|
54
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
55
|
+
exitCode: 2,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (input.agentSmoke && !input.confirmAgentSmokeCost) {
|
|
59
|
+
throw setupError({
|
|
60
|
+
code: "RESEARCH_SETUP_CONFIRMATION_REQUIRED",
|
|
61
|
+
step: "plan",
|
|
62
|
+
reason: "Agent smoke checks may consume provider quota and were not confirmed.",
|
|
63
|
+
minimumAction: "Confirm agent-smoke cost, or defer the smoke check.",
|
|
64
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
65
|
+
exitCode: 2,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (input.allowSyntheticUnstructureUpload && !input.liveChecks) {
|
|
69
|
+
throw setupError({
|
|
70
|
+
code: "RESEARCH_SETUP_CONFIRMATION_REQUIRED",
|
|
71
|
+
step: "plan",
|
|
72
|
+
reason: "Synthetic Unstructure upload can be authorized only as part of explicit live checks.",
|
|
73
|
+
minimumAction: "Enable live checks as well, or omit the synthetic-upload authorization.",
|
|
74
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
75
|
+
exitCode: 2,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (input.mode === "production-research" && input.evidenceProfile === "none") {
|
|
79
|
+
throw setupError({
|
|
80
|
+
code: "RESEARCH_SETUP_SELECTION_INVALID",
|
|
81
|
+
step: "selection",
|
|
82
|
+
reason: "Production research requires an independent public-internet evidence profile.",
|
|
83
|
+
minimumAction: `Choose ${EXTERNAL_SKILL_PROFILE} or a broader explicit profile.`,
|
|
84
|
+
retryCommand: "tiangong-ai research setup catalog --json",
|
|
85
|
+
exitCode: 2,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const selected = resolveSetupSkills([
|
|
89
|
+
...new Set([...BRAVE_PROFILE_SKILLS[input.evidenceProfile], ...input.skillIds]),
|
|
90
|
+
]);
|
|
91
|
+
if (selected.some((skill) => skill.role === "evidence-capability") && !agents.includes("codex")) {
|
|
92
|
+
throw setupError({
|
|
93
|
+
code: "RESEARCH_SETUP_SELECTION_INVALID",
|
|
94
|
+
step: "selection",
|
|
95
|
+
reason: "Evidence capabilities must be copied to the Codex-compatible .agents/skills root.",
|
|
96
|
+
minimumAction: "Include codex in --agents for any evidence-capability selection.",
|
|
97
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
98
|
+
exitCode: 2,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
validateEvidenceProfileSelection(input.evidenceProfile, selected);
|
|
102
|
+
validateLicenseAcceptances(selected, input.acceptedLicenseIds);
|
|
103
|
+
const settings = normalizedSettings(selected, input.settings ?? {});
|
|
104
|
+
const credentialSources = normalizedCredentialSources(selected, input.credentialEnvironment ?? {});
|
|
105
|
+
const agentRoutes = normalizeAgentRoutes(input.agentRoutes);
|
|
106
|
+
const targets = plannedInstallTargets(root, scope, agents, input.environment ?? process.env, input.targetRoots);
|
|
107
|
+
const selectedSources = [...new Set(selected.map((skill) => skill.sourceId))]
|
|
108
|
+
.map(setupSource)
|
|
109
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
110
|
+
const unsigned = {
|
|
111
|
+
schemaVersion: 1,
|
|
112
|
+
kind: "tiangong-research-setup-plan",
|
|
113
|
+
planId: randomUUID(),
|
|
114
|
+
createdAt: new Date().toISOString(),
|
|
115
|
+
cli: { package: "@tiangong-ai/cli", version: packageVersion() },
|
|
116
|
+
workspace: {
|
|
117
|
+
path: root,
|
|
118
|
+
name: normalizedWorkspaceName(input.name ?? basename(root)),
|
|
119
|
+
mode: input.mode,
|
|
120
|
+
},
|
|
121
|
+
install: {
|
|
122
|
+
scope,
|
|
123
|
+
agents,
|
|
124
|
+
mode: "copy",
|
|
125
|
+
installer: RESEARCH_SETUP_INSTALLER,
|
|
126
|
+
targets,
|
|
127
|
+
},
|
|
128
|
+
selection: {
|
|
129
|
+
evidenceProfile: input.evidenceProfile,
|
|
130
|
+
skillIds: selected.map((skill) => skill.id),
|
|
131
|
+
},
|
|
132
|
+
sources: selectedSources.map((source) => ({
|
|
133
|
+
id: source.id,
|
|
134
|
+
repository: source.repository,
|
|
135
|
+
locator: source.locator,
|
|
136
|
+
immutableRef: source.immutableRef,
|
|
137
|
+
})),
|
|
138
|
+
skills: selected.map((skill) => ({
|
|
139
|
+
id: skill.id,
|
|
140
|
+
skillName: skill.skillName,
|
|
141
|
+
sourceId: skill.sourceId,
|
|
142
|
+
sourceRelativePath: skill.sourceRelativePath,
|
|
143
|
+
expectedTreeSha256: skill.expectedTreeSha256,
|
|
144
|
+
role: skill.role,
|
|
145
|
+
licenseId: skill.license.id,
|
|
146
|
+
})),
|
|
147
|
+
acceptedLicenses: selected.map((skill) => ({
|
|
148
|
+
skillId: skill.id,
|
|
149
|
+
licenseId: skill.license.id,
|
|
150
|
+
accepted: true,
|
|
151
|
+
})),
|
|
152
|
+
credentialSources,
|
|
153
|
+
settings,
|
|
154
|
+
agentRoutes,
|
|
155
|
+
checks: {
|
|
156
|
+
live: input.liveChecks === true,
|
|
157
|
+
allowSyntheticUnstructureUpload: input.allowSyntheticUnstructureUpload === true,
|
|
158
|
+
agentSmoke: input.agentSmoke === true,
|
|
159
|
+
},
|
|
160
|
+
confirmations: {
|
|
161
|
+
networkDownloads: true,
|
|
162
|
+
globalMutation: scope === "global",
|
|
163
|
+
agentSmokeCost: input.agentSmoke === true,
|
|
164
|
+
},
|
|
165
|
+
mutations: setupMutations(root, targets, selected, credentialSources),
|
|
166
|
+
};
|
|
167
|
+
const plan = {
|
|
168
|
+
...unsigned,
|
|
169
|
+
planSha256: sha256Text(canonicalJson(unsigned)),
|
|
170
|
+
};
|
|
171
|
+
const paths = workspacePaths(root);
|
|
172
|
+
await ensureDirectory(paths.control);
|
|
173
|
+
const release = await acquireFileLock(paths.setupLock, setupLockPayload(plan.planSha256));
|
|
174
|
+
try {
|
|
175
|
+
if ((await pathExists(paths.setupPlan)) && !input.replacePlan) {
|
|
176
|
+
throw setupError({
|
|
177
|
+
code: "RESEARCH_SETUP_PLAN_EXISTS",
|
|
178
|
+
step: "plan",
|
|
179
|
+
reason: "A setup plan already exists and was not replaced implicitly.",
|
|
180
|
+
minimumAction: "Inspect setup status, then explicitly request plan replacement if appropriate.",
|
|
181
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
182
|
+
exitCode: 3,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const priorPlanSha256 = (await pathExists(paths.setupPlan))
|
|
186
|
+
? await archiveSetupGeneration(root)
|
|
187
|
+
: null;
|
|
188
|
+
await writeJsonAtomic(paths.setupPlan, plan, 0o444);
|
|
189
|
+
await writeJsonAtomic(paths.setupState, initialSetupState(plan.planSha256));
|
|
190
|
+
if (priorPlanSha256 && (await pathExists(paths.marker))) {
|
|
191
|
+
await appendJournalEvent(paths.journal, "research.setup.plan.replaced", "workspace", {
|
|
192
|
+
priorPlanSha256,
|
|
193
|
+
planSha256: plan.planSha256,
|
|
194
|
+
}).catch(() => undefined);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await release();
|
|
199
|
+
}
|
|
200
|
+
return plan;
|
|
201
|
+
}
|
|
202
|
+
export async function loadAndVerifyResearchSetupPlan(planPath) {
|
|
203
|
+
const plan = await loadHashVerifiedResearchSetupPlan(planPath);
|
|
204
|
+
if (plan.cli.version !== packageVersion()) {
|
|
205
|
+
throw setupError({
|
|
206
|
+
code: "RESEARCH_SETUP_CLI_DRIFT",
|
|
207
|
+
step: "plan-validation",
|
|
208
|
+
reason: `Plan requires @tiangong-ai/cli@${plan.cli.version}; active version is ${packageVersion()}.`,
|
|
209
|
+
minimumAction: "Use the plan's exact CLI version or generate a new plan and review its changes.",
|
|
210
|
+
retryCommand: "tiangong-ai --version",
|
|
211
|
+
exitCode: 3,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
assertPlanMatchesCatalog(plan);
|
|
215
|
+
return plan;
|
|
216
|
+
}
|
|
217
|
+
async function loadHashVerifiedResearchSetupPlan(planPath) {
|
|
218
|
+
if (!isAbsolute(planPath)) {
|
|
219
|
+
throw setupError({
|
|
220
|
+
code: "RESEARCH_SETUP_PLAN_INVALID",
|
|
221
|
+
step: "plan-validation",
|
|
222
|
+
reason: "Setup plan path must be absolute.",
|
|
223
|
+
minimumAction: "Pass the absolute setup-plan.json path.",
|
|
224
|
+
retryCommand: "tiangong-ai research setup apply --help",
|
|
225
|
+
exitCode: 2,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const info = await lstat(planPath).catch(() => undefined);
|
|
229
|
+
if (!info?.isFile() || info.isSymbolicLink()) {
|
|
230
|
+
throw setupError({
|
|
231
|
+
code: "RESEARCH_SETUP_PLAN_INVALID",
|
|
232
|
+
step: "plan-validation",
|
|
233
|
+
reason: "Setup plan must be a regular non-symlink file.",
|
|
234
|
+
minimumAction: "Restore the immutable plan file at an absolute path.",
|
|
235
|
+
retryCommand: "tiangong-ai research setup status --json",
|
|
236
|
+
exitCode: 2,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const raw = await readJsonFile(planPath, "Research setup plan");
|
|
240
|
+
const plan = parseResearchSetupPlan(raw);
|
|
241
|
+
const { planSha256: _hash, ...unsigned } = plan;
|
|
242
|
+
const expected = sha256Text(canonicalJson(unsigned));
|
|
243
|
+
if (plan.planSha256 !== expected) {
|
|
244
|
+
throw setupError({
|
|
245
|
+
code: "RESEARCH_SETUP_PLAN_TAMPERED",
|
|
246
|
+
step: "plan-validation",
|
|
247
|
+
reason: "Setup plan hash does not match its content.",
|
|
248
|
+
minimumAction: "Discard the changed plan and create a new reviewed plan.",
|
|
249
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
250
|
+
exitCode: 3,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return plan;
|
|
254
|
+
}
|
|
255
|
+
export async function createResearchSetupUpgradePlan(input) {
|
|
256
|
+
const root = requireAbsoluteWorkspace(input.workspace);
|
|
257
|
+
if (!input.confirmUpgrade) {
|
|
258
|
+
throw setupError({
|
|
259
|
+
code: "RESEARCH_SETUP_CONFIRMATION_REQUIRED",
|
|
260
|
+
step: "upgrade-plan",
|
|
261
|
+
reason: "Setup upgrade plan generation requires an explicit confirmation.",
|
|
262
|
+
minimumAction: "Run update --check, review current catalog licenses and pins, then pass --confirm-upgrade.",
|
|
263
|
+
retryCommand: `tiangong-ai research setup update --check --workspace ${root} --json`,
|
|
264
|
+
exitCode: 2,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
const prior = await loadHashVerifiedResearchSetupPlan(workspacePaths(root).setupPlan);
|
|
268
|
+
const selected = resolveSetupSkills(prior.selection.skillIds);
|
|
269
|
+
validateLicenseAcceptances(selected, input.acceptedLicenseIds);
|
|
270
|
+
return createResearchSetupPlan({
|
|
271
|
+
workspace: root,
|
|
272
|
+
name: prior.workspace.name,
|
|
273
|
+
mode: prior.workspace.mode,
|
|
274
|
+
evidenceProfile: prior.selection.evidenceProfile,
|
|
275
|
+
skillIds: prior.selection.skillIds.filter((id) => !BRAVE_PROFILE_SKILLS[prior.selection.evidenceProfile].includes(id)),
|
|
276
|
+
scope: prior.install.scope,
|
|
277
|
+
agents: prior.install.agents,
|
|
278
|
+
acceptedLicenseIds: input.acceptedLicenseIds,
|
|
279
|
+
credentialEnvironment: Object.fromEntries(prior.credentialSources.map((credential) => [credential.id, credential.fromEnvironment])),
|
|
280
|
+
settings: prior.settings,
|
|
281
|
+
agentRoutes: prior.agentRoutes,
|
|
282
|
+
liveChecks: prior.checks.live,
|
|
283
|
+
allowSyntheticUnstructureUpload: prior.checks.allowSyntheticUnstructureUpload,
|
|
284
|
+
agentSmoke: prior.checks.agentSmoke,
|
|
285
|
+
confirmNetworkDownloads: true,
|
|
286
|
+
confirmGlobalMutation: prior.install.scope === "global",
|
|
287
|
+
confirmAgentSmokeCost: prior.checks.agentSmoke,
|
|
288
|
+
replacePlan: true,
|
|
289
|
+
targetRoots: Object.fromEntries(prior.install.targets.map((target) => [target.agent, target.root])),
|
|
290
|
+
...(input.environment === undefined ? {} : { environment: input.environment }),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
export async function applyResearchSetupPlan(planPath, options = {}) {
|
|
294
|
+
const plan = await loadAndVerifyResearchSetupPlan(resolve(planPath));
|
|
295
|
+
const root = plan.workspace.path;
|
|
296
|
+
const paths = workspacePaths(root);
|
|
297
|
+
const environment = options.environment ?? process.env;
|
|
298
|
+
const runner = sanitizingSetupRunner(options.runner ?? runSetupCommand, setupSecretValues(plan, environment));
|
|
299
|
+
const release = await acquireFileLock(paths.setupLock, setupLockPayload(plan.planSha256));
|
|
300
|
+
let state = await loadSetupState(root, plan.planSha256);
|
|
301
|
+
state = await updateSetupState(root, {
|
|
302
|
+
...state,
|
|
303
|
+
status: "applying",
|
|
304
|
+
attempts: state.attempts + 1,
|
|
305
|
+
currentStep: "workspace",
|
|
306
|
+
lastError: null,
|
|
307
|
+
});
|
|
308
|
+
try {
|
|
309
|
+
await ensureSetupWorkspace(plan);
|
|
310
|
+
await configureAgentRoutes(plan);
|
|
311
|
+
state = await completeSetupStep(root, state, "workspace");
|
|
312
|
+
state = await startSetupStep(root, state, "credential-preflight");
|
|
313
|
+
await assertRequiredCredentialPreflight(plan, environment);
|
|
314
|
+
state = await completeSetupStep(root, state, "credential-preflight");
|
|
315
|
+
state = await startSetupStep(root, state, "installation-preflight");
|
|
316
|
+
const selected = plan.selection.skillIds.map(setupSkill);
|
|
317
|
+
const installInspection = await inspectSelectedInstallations(plan, selected, environment);
|
|
318
|
+
const unsafe = installInspection.filter((item) => item.status === "drifted" || item.status === "blocked");
|
|
319
|
+
if (unsafe.length) {
|
|
320
|
+
throw setupError({
|
|
321
|
+
code: "RESEARCH_SETUP_INSTALL_DESTINATION_UNSAFE",
|
|
322
|
+
step: "installation-preflight",
|
|
323
|
+
reason: `Existing install destinations are unsafe or drifted: ${unsafe
|
|
324
|
+
.map((item) => `${item.agent}:${item.skillId}`)
|
|
325
|
+
.join(", ")}.`,
|
|
326
|
+
minimumAction: "Review the existing directories. The setup CLI will not overwrite, delete, or choose between ambiguous Skill bytes.",
|
|
327
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
328
|
+
exitCode: 3,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
state = await completeSetupStep(root, state, "installation-preflight");
|
|
332
|
+
const missing = installInspection.filter((item) => item.status === "missing");
|
|
333
|
+
if (missing.length) {
|
|
334
|
+
state = await startSetupStep(root, state, "source-checkout");
|
|
335
|
+
await verifyInstallerPackage(runner, root, installerEnvironment(environment));
|
|
336
|
+
const requiredSourceIds = [
|
|
337
|
+
...new Set(missing.map((item) => setupSkill(item.skillId).sourceId)),
|
|
338
|
+
].sort();
|
|
339
|
+
const sourceDirectories = new Map();
|
|
340
|
+
for (const sourceId of requiredSourceIds) {
|
|
341
|
+
sourceDirectories.set(sourceId, await ensureSetupSourceCheckout(plan, sourceId, runner, installerEnvironment(environment)));
|
|
342
|
+
}
|
|
343
|
+
state = await completeSetupStep(root, state, "source-checkout");
|
|
344
|
+
state = await startSetupStep(root, state, "skill-install");
|
|
345
|
+
for (const agent of plan.install.agents) {
|
|
346
|
+
const missingForAgent = missing.filter((item) => item.agent === agent);
|
|
347
|
+
const sourceIds = [
|
|
348
|
+
...new Set(missingForAgent.map((item) => setupSkill(item.skillId).sourceId)),
|
|
349
|
+
].sort();
|
|
350
|
+
for (const sourceId of sourceIds) {
|
|
351
|
+
const skills = missingForAgent
|
|
352
|
+
.map((item) => setupSkill(item.skillId))
|
|
353
|
+
.filter((skill) => skill.sourceId === sourceId)
|
|
354
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
355
|
+
await installSetupSkills({
|
|
356
|
+
plan,
|
|
357
|
+
agent,
|
|
358
|
+
skills,
|
|
359
|
+
sourceDirectory: sourceDirectories.get(sourceId),
|
|
360
|
+
runner,
|
|
361
|
+
environment: installerEnvironmentForTarget(plan, agent, environment),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const verified = await inspectSelectedInstallations(plan, selected, environment);
|
|
366
|
+
const incomplete = verified.filter((item) => item.status !== "installed");
|
|
367
|
+
if (incomplete.length) {
|
|
368
|
+
throw setupError({
|
|
369
|
+
code: "RESEARCH_SETUP_INSTALL_VERIFICATION_FAILED",
|
|
370
|
+
step: "skill-install",
|
|
371
|
+
reason: `The installer exited but ${incomplete.length} destination(s) did not match the reviewed hashes.`,
|
|
372
|
+
minimumAction: "Inspect the reported destination status. File existence alone is not accepted as installation success.",
|
|
373
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
374
|
+
exitCode: 3,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
state = await completeSetupStep(root, state, "skill-install");
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
state = await completeSetupStep(root, state, "skill-install");
|
|
381
|
+
}
|
|
382
|
+
state = await startSetupStep(root, state, "capability-configuration");
|
|
383
|
+
await configureSelectedCapabilities(plan, environment);
|
|
384
|
+
state = await completeSetupStep(root, state, "capability-configuration");
|
|
385
|
+
state = await startSetupStep(root, state, "settings");
|
|
386
|
+
await writeJsonAtomic(paths.setupConfig, {
|
|
387
|
+
schemaVersion: 1,
|
|
388
|
+
planSha256: plan.planSha256,
|
|
389
|
+
settings: plan.settings,
|
|
390
|
+
selectedSkillIds: plan.selection.skillIds,
|
|
391
|
+
updatedAt: new Date().toISOString(),
|
|
392
|
+
});
|
|
393
|
+
state = await completeSetupStep(root, state, "settings");
|
|
394
|
+
state = await startSetupStep(root, state, "credentials");
|
|
395
|
+
await configurePlanCredentials(plan, environment);
|
|
396
|
+
state = await completeSetupStep(root, state, "credentials");
|
|
397
|
+
await appendJournalEvent(paths.journal, "research.setup.applied", "workspace", {
|
|
398
|
+
planSha256: plan.planSha256,
|
|
399
|
+
selectedSkillIds: plan.selection.skillIds,
|
|
400
|
+
sourcePins: plan.sources.map((source) => ({
|
|
401
|
+
id: source.id,
|
|
402
|
+
immutableRef: source.immutableRef,
|
|
403
|
+
})),
|
|
404
|
+
installer: {
|
|
405
|
+
package: plan.install.installer.package,
|
|
406
|
+
version: plan.install.installer.version,
|
|
407
|
+
npmIntegrity: plan.install.installer.npmIntegrity,
|
|
408
|
+
},
|
|
409
|
+
configuredCredentialIds: plan.credentialSources.map((credential) => credential.id),
|
|
410
|
+
});
|
|
411
|
+
if (options.skipDoctor) {
|
|
412
|
+
state = await updateSetupState(root, {
|
|
413
|
+
...state,
|
|
414
|
+
status: "partially-ready",
|
|
415
|
+
currentStep: null,
|
|
416
|
+
});
|
|
417
|
+
return { schemaVersion: 1, plan, state, report: null };
|
|
418
|
+
}
|
|
419
|
+
state = await startSetupStep(root, state, "doctor");
|
|
420
|
+
const report = await doctorResearchSetup(root, {
|
|
421
|
+
live: plan.checks.live,
|
|
422
|
+
allowSyntheticUnstructureUpload: plan.checks.allowSyntheticUnstructureUpload,
|
|
423
|
+
agentSmoke: plan.checks.agentSmoke,
|
|
424
|
+
environment,
|
|
425
|
+
runner,
|
|
426
|
+
...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }),
|
|
427
|
+
...(options.sleeper === undefined ? {} : { sleeper: options.sleeper }),
|
|
428
|
+
});
|
|
429
|
+
state = await updateSetupState(root, {
|
|
430
|
+
...state,
|
|
431
|
+
status: report.readiness === "READY"
|
|
432
|
+
? "ready"
|
|
433
|
+
: report.readiness === "PARTIALLY_READY"
|
|
434
|
+
? "partially-ready"
|
|
435
|
+
: "blocked",
|
|
436
|
+
currentStep: null,
|
|
437
|
+
completedSteps: [...new Set([...state.completedSteps, "doctor"])],
|
|
438
|
+
});
|
|
439
|
+
return { schemaVersion: 1, plan, state, report };
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
const failure = setupFailure(error, state.currentStep ?? "apply", root);
|
|
443
|
+
state = await updateSetupState(root, {
|
|
444
|
+
...state,
|
|
445
|
+
status: "blocked",
|
|
446
|
+
currentStep: null,
|
|
447
|
+
lastError: failure,
|
|
448
|
+
});
|
|
449
|
+
if (error instanceof CliError)
|
|
450
|
+
throw error;
|
|
451
|
+
throw setupError({
|
|
452
|
+
code: failure.code,
|
|
453
|
+
step: failure.step,
|
|
454
|
+
reason: failure.reason,
|
|
455
|
+
minimumAction: failure.minimumAction,
|
|
456
|
+
retryCommand: failure.retryCommand,
|
|
457
|
+
exitCode: 3,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
await release();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
export async function inspectResearchSetupStatus(workspace, environment = process.env) {
|
|
465
|
+
const root = requireAbsoluteWorkspace(resolve(workspace));
|
|
466
|
+
const paths = workspacePaths(root);
|
|
467
|
+
const plan = await loadAndVerifyResearchSetupPlan(paths.setupPlan);
|
|
468
|
+
const state = await loadSetupState(root, plan.planSha256);
|
|
469
|
+
const selected = plan.selection.skillIds.map(setupSkill);
|
|
470
|
+
const installations = await inspectSelectedInstallations(plan, selected, environment);
|
|
471
|
+
const report = (await pathExists(paths.setupReport))
|
|
472
|
+
? await readJsonFile(paths.setupReport, "Research setup report")
|
|
473
|
+
: null;
|
|
474
|
+
return {
|
|
475
|
+
schemaVersion: 1,
|
|
476
|
+
workspace: root,
|
|
477
|
+
plan: {
|
|
478
|
+
planId: plan.planId,
|
|
479
|
+
planSha256: plan.planSha256,
|
|
480
|
+
cliVersion: plan.cli.version,
|
|
481
|
+
mode: plan.workspace.mode,
|
|
482
|
+
scope: plan.install.scope,
|
|
483
|
+
agents: plan.install.agents,
|
|
484
|
+
selectedSkillIds: plan.selection.skillIds,
|
|
485
|
+
},
|
|
486
|
+
state,
|
|
487
|
+
installations,
|
|
488
|
+
report,
|
|
489
|
+
next: state.status === "blocked" && state.lastError
|
|
490
|
+
? state.lastError
|
|
491
|
+
: state.status === "ready"
|
|
492
|
+
? null
|
|
493
|
+
: {
|
|
494
|
+
minimumAction: "Run setup doctor and resolve every reported missing readiness item.",
|
|
495
|
+
retryCommand: `tiangong-ai research setup doctor --workspace ${root} --json`,
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
export async function setResearchSetupCredentialFromEnvironment(input) {
|
|
500
|
+
const root = requireAbsoluteWorkspace(input.workspace);
|
|
501
|
+
const plan = await loadAndVerifyResearchSetupPlan(workspacePaths(root).setupPlan);
|
|
502
|
+
const selected = selectedCredentialDefinitions(plan);
|
|
503
|
+
const credential = selected.find((candidate) => candidate.id === input.credentialId);
|
|
504
|
+
if (!credential) {
|
|
505
|
+
throw setupError({
|
|
506
|
+
code: "RESEARCH_SETUP_CREDENTIAL_INVALID",
|
|
507
|
+
step: "credentials",
|
|
508
|
+
reason: `Credential is not declared by the selected setup plan: ${input.credentialId}.`,
|
|
509
|
+
minimumAction: "Inspect the setup catalog and selected plan credential IDs.",
|
|
510
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
511
|
+
exitCode: 2,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
assertEnvironmentName(input.environmentName);
|
|
515
|
+
const value = input.environment[input.environmentName];
|
|
516
|
+
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") < credential.minimumUtf8Bytes) {
|
|
517
|
+
throw setupError({
|
|
518
|
+
code: "RESEARCH_SETUP_CREDENTIAL_INVALID",
|
|
519
|
+
step: "credentials",
|
|
520
|
+
reason: `Credential source environment variable is missing or too short: ${input.environmentName}.`,
|
|
521
|
+
minimumAction: "Set the owner environment variable, then retry this exact credential step.",
|
|
522
|
+
retryCommand: `tiangong-ai research setup credential set --id ${credential.id} --from-env ${input.environmentName} --workspace ${root} --json`,
|
|
523
|
+
exitCode: 3,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
if (credential.storage === "broker") {
|
|
527
|
+
const declarations = await loadCapabilityDeclarations(root);
|
|
528
|
+
await setCapabilityCredentialFromEnvironment({
|
|
529
|
+
root,
|
|
530
|
+
capabilities: declarations.capabilities,
|
|
531
|
+
credentialId: credential.id,
|
|
532
|
+
environmentName: input.environmentName,
|
|
533
|
+
environment: input.environment,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
await setAdapterCredential(root, selected, credential.id, value);
|
|
538
|
+
}
|
|
539
|
+
await appendJournalEvent(workspacePaths(root).journal, "research.setup.credential.configured", "workspace", {
|
|
540
|
+
credentialId: credential.id,
|
|
541
|
+
sourceEnvironmentNameSha256: sha256Text(input.environmentName),
|
|
542
|
+
storage: credential.storage,
|
|
543
|
+
});
|
|
544
|
+
return {
|
|
545
|
+
schemaVersion: 1,
|
|
546
|
+
workspace: root,
|
|
547
|
+
credentialId: credential.id,
|
|
548
|
+
configured: true,
|
|
549
|
+
storage: credential.storage,
|
|
550
|
+
outputPolicy: "value-is-never-emitted",
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
export async function runResearchSetupCompanion(input, options = {}) {
|
|
554
|
+
const root = requireAbsoluteWorkspace(input.workspace);
|
|
555
|
+
const plan = await loadAndVerifyResearchSetupPlan(workspacePaths(root).setupPlan);
|
|
556
|
+
if (!plan.selection.skillIds.includes(input.skillId)) {
|
|
557
|
+
throw setupError({
|
|
558
|
+
code: "RESEARCH_SETUP_COMPANION_NOT_SELECTED",
|
|
559
|
+
step: "companion-preflight",
|
|
560
|
+
reason: `The immutable setup plan did not select ${input.skillId}.`,
|
|
561
|
+
minimumAction: "Create and apply an explicit replacement setup plan that selects this companion Skill.",
|
|
562
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
563
|
+
exitCode: 3,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
const skill = setupSkill(input.skillId);
|
|
567
|
+
if (skill.role !== "input-preprocessor" && skill.role !== "acquisition-adapter") {
|
|
568
|
+
throw setupError({
|
|
569
|
+
code: "RESEARCH_SETUP_COMPANION_ROLE_INVALID",
|
|
570
|
+
step: "companion-preflight",
|
|
571
|
+
reason: `${skill.id} is not an input-preprocessor or acquisition-adapter.`,
|
|
572
|
+
minimumAction: "Use evidence capabilities through the research broker and authoring Skills only after closure.",
|
|
573
|
+
retryCommand: `tiangong-ai research setup catalog --workspace ${root} --json`,
|
|
574
|
+
exitCode: 2,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
const skillDirectory = await verifiedCompanionSkillDirectory(plan, skill);
|
|
578
|
+
const credentialDefinitions = selectedCredentialDefinitions(plan).filter((definition) => skill.credentialIds.includes(definition.id));
|
|
579
|
+
const credentials = await loadAdapterCredentials(root, selectedCredentialDefinitions(plan));
|
|
580
|
+
const environment = options.environment ?? process.env;
|
|
581
|
+
const runner = sanitizingSetupRunner(options.runner ?? runSetupCommand, [
|
|
582
|
+
...setupSecretValues(plan, environment),
|
|
583
|
+
...credentials.values(),
|
|
584
|
+
]);
|
|
585
|
+
return input.skillId === "tiangong.document-granular-decompose"
|
|
586
|
+
? runDocumentGranularCompanion({
|
|
587
|
+
root,
|
|
588
|
+
plan,
|
|
589
|
+
skill,
|
|
590
|
+
skillDirectory,
|
|
591
|
+
credentialDefinitions,
|
|
592
|
+
credentials,
|
|
593
|
+
input,
|
|
594
|
+
environment,
|
|
595
|
+
runner,
|
|
596
|
+
})
|
|
597
|
+
: runAcademicPaperCompanion({
|
|
598
|
+
root,
|
|
599
|
+
plan,
|
|
600
|
+
skill,
|
|
601
|
+
skillDirectory,
|
|
602
|
+
credentials,
|
|
603
|
+
input,
|
|
604
|
+
environment,
|
|
605
|
+
runner,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
export async function doctorResearchSetup(workspace, options = {}) {
|
|
609
|
+
const root = requireAbsoluteWorkspace(resolve(workspace));
|
|
610
|
+
const environment = options.environment ?? process.env;
|
|
611
|
+
const fetcher = options.fetcher ?? fetch;
|
|
612
|
+
const sleeper = options.sleeper ?? sleep;
|
|
613
|
+
const paths = workspacePaths(root);
|
|
614
|
+
const plan = await loadAndVerifyResearchSetupPlan(paths.setupPlan);
|
|
615
|
+
const runner = sanitizingSetupRunner(options.runner ?? runSetupCommand, setupSecretValues(plan, environment));
|
|
616
|
+
const checks = [];
|
|
617
|
+
await setupDoctorCheck(checks, "workspace", "workspace", async () => {
|
|
618
|
+
const context = await inspectResearchContext(root);
|
|
619
|
+
if (context.role !== "workspace")
|
|
620
|
+
throw new Error(`workspace role is ${context.role}`);
|
|
621
|
+
return `Workspace is initialized in ${plan.workspace.mode} mode.`;
|
|
622
|
+
});
|
|
623
|
+
await setupDoctorCheck(checks, "node", "runtime", async () => {
|
|
624
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
625
|
+
if (major !== 24)
|
|
626
|
+
throw new Error(`Node ${process.versions.node} does not satisfy >=24 <25`);
|
|
627
|
+
return `Node ${process.versions.node} matches the CLI runtime baseline.`;
|
|
628
|
+
});
|
|
629
|
+
for (const command of ["git", "npx"]) {
|
|
630
|
+
await setupDoctorCheck(checks, command, "runtime", async () => {
|
|
631
|
+
const result = await runner({
|
|
632
|
+
command,
|
|
633
|
+
args: ["--version"],
|
|
634
|
+
cwd: root,
|
|
635
|
+
environment: installerEnvironment(environment),
|
|
636
|
+
timeoutMs: 15_000,
|
|
637
|
+
});
|
|
638
|
+
if (result.exitCode !== 0)
|
|
639
|
+
throw new Error(`${command} is not executable`);
|
|
640
|
+
return `${command} is executable (${sanitizeResearchText(result.stdout.trim()).slice(0, 120)}).`;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
await setupDoctorCheck(checks, "platform-sandbox", "runtime", async () => {
|
|
644
|
+
if (platform() === "darwin") {
|
|
645
|
+
const info = await lstat("/usr/bin/sandbox-exec").catch(() => undefined);
|
|
646
|
+
if (!info?.isFile())
|
|
647
|
+
throw new Error("/usr/bin/sandbox-exec is unavailable");
|
|
648
|
+
return "macOS sandbox-exec is available.";
|
|
649
|
+
}
|
|
650
|
+
if (platform() === "linux") {
|
|
651
|
+
const result = await runner({
|
|
652
|
+
command: "bwrap",
|
|
653
|
+
args: ["--version"],
|
|
654
|
+
cwd: root,
|
|
655
|
+
environment: installerEnvironment(environment),
|
|
656
|
+
timeoutMs: 15_000,
|
|
657
|
+
});
|
|
658
|
+
if (result.exitCode !== 0)
|
|
659
|
+
throw new Error("Bubblewrap is unavailable");
|
|
660
|
+
return "Linux Bubblewrap is available.";
|
|
661
|
+
}
|
|
662
|
+
throw new Error("Research execution is unsupported on this platform");
|
|
663
|
+
});
|
|
664
|
+
for (const command of ["codex", "claude"]) {
|
|
665
|
+
await setupDoctorCheck(checks, `agent.${command}`, "agent", async () => {
|
|
666
|
+
const result = await runner({
|
|
667
|
+
command,
|
|
668
|
+
args: ["--version"],
|
|
669
|
+
cwd: root,
|
|
670
|
+
environment: agentDoctorEnvironment(environment),
|
|
671
|
+
timeoutMs: 30_000,
|
|
672
|
+
});
|
|
673
|
+
if (result.exitCode !== 0)
|
|
674
|
+
throw new Error(`${command} is not executable`);
|
|
675
|
+
return `${command} is executable (${sanitizeResearchText(result.stdout.trim()).slice(0, 160)}).`;
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
const selected = plan.selection.skillIds.map(setupSkill);
|
|
679
|
+
const installations = await inspectSelectedInstallations(plan, selected, environment);
|
|
680
|
+
for (const installation of installations) {
|
|
681
|
+
checks.push({
|
|
682
|
+
id: `skill.${installation.agent}.${installation.skillId}`,
|
|
683
|
+
category: "skill-installation",
|
|
684
|
+
status: installation.status === "installed" ? "pass" : "fail",
|
|
685
|
+
detail: installation.detail,
|
|
686
|
+
minimumAction: installation.status === "installed"
|
|
687
|
+
? null
|
|
688
|
+
: "Restore the pinned Skill bytes; setup will not overwrite a drifted or symlinked directory.",
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
for (const setting of requiredSettingsForSkills(selected)) {
|
|
692
|
+
const configured = plan.settings[setting.id];
|
|
693
|
+
checks.push({
|
|
694
|
+
id: `setting.${setting.id}`,
|
|
695
|
+
category: "configuration",
|
|
696
|
+
status: configured ? "pass" : setting.required ? "fail" : "warn",
|
|
697
|
+
detail: configured
|
|
698
|
+
? "Declared non-secret setting is configured."
|
|
699
|
+
: "Setting is not configured.",
|
|
700
|
+
minimumAction: configured
|
|
701
|
+
? null
|
|
702
|
+
: `Create a reviewed replacement plan with the ${setting.id} setting.`,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
let brokerCredentialStatus = null;
|
|
706
|
+
try {
|
|
707
|
+
const declarations = await loadCapabilityDeclarations(root);
|
|
708
|
+
brokerCredentialStatus = await inspectCapabilityCredentialEnvironment(root, declarations.capabilities);
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
checks.push({
|
|
712
|
+
id: "credential.broker-store",
|
|
713
|
+
category: "credential",
|
|
714
|
+
status: "fail",
|
|
715
|
+
detail: sanitizeResearchText(error instanceof Error ? error.message : String(error)),
|
|
716
|
+
minimumAction: "Repair the owner-only broker credential file, then rerun setup doctor.",
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
let adapterCredentials = new Map();
|
|
720
|
+
try {
|
|
721
|
+
adapterCredentials = await loadAdapterCredentials(root, selectedCredentialDefinitions(plan));
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
checks.push({
|
|
725
|
+
id: "credential.adapter-store",
|
|
726
|
+
category: "credential",
|
|
727
|
+
status: "fail",
|
|
728
|
+
detail: sanitizeResearchText(error instanceof Error ? error.message : String(error)),
|
|
729
|
+
minimumAction: "Repair the owner-only adapter credential file, then rerun setup doctor.",
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
for (const credential of selectedCredentialDefinitions(plan)) {
|
|
733
|
+
const configured = credential.storage === "broker"
|
|
734
|
+
? (brokerCredentialStatus?.configuredIds.includes(credential.id) ?? false)
|
|
735
|
+
: adapterCredentials.has(credential.id);
|
|
736
|
+
checks.push({
|
|
737
|
+
id: `credential.${credential.id}`,
|
|
738
|
+
category: "credential",
|
|
739
|
+
status: configured ? "pass" : credential.required ? "fail" : "warn",
|
|
740
|
+
detail: configured
|
|
741
|
+
? "Credential is present in an owner-only store; its value was not emitted."
|
|
742
|
+
: "Credential is not configured.",
|
|
743
|
+
minimumAction: configured
|
|
744
|
+
? null
|
|
745
|
+
: `Run research setup credential set --id ${credential.id} --from-env <OWNER_ENV_NAME> --workspace ${root}.`,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
await appendDependencyChecks(checks, selected, runner, root, environment);
|
|
749
|
+
let capabilityDoctor = null;
|
|
750
|
+
if (selected.some((skill) => skill.role === "evidence-capability")) {
|
|
751
|
+
try {
|
|
752
|
+
capabilityDoctor = await doctorExternalCapabilities(root, {
|
|
753
|
+
live: options.live === true,
|
|
754
|
+
fetcher,
|
|
755
|
+
sleeper,
|
|
756
|
+
});
|
|
757
|
+
checks.push({
|
|
758
|
+
id: "capabilities",
|
|
759
|
+
category: "evidence-capability",
|
|
760
|
+
status: capabilityDoctor.status === "ready" ? "pass" : "fail",
|
|
761
|
+
detail: `${capabilityDoctor.capabilities.length} capability declaration(s); mode=${capabilityDoctor.mode}.`,
|
|
762
|
+
minimumAction: capabilityDoctor.status === "ready"
|
|
763
|
+
? null
|
|
764
|
+
: "Resolve the exact static or live capability failures; no provider fallback is performed.",
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
checks.push({
|
|
769
|
+
id: "capabilities",
|
|
770
|
+
category: "evidence-capability",
|
|
771
|
+
status: "fail",
|
|
772
|
+
detail: sanitizeResearchText(error instanceof Error ? error.message : String(error)),
|
|
773
|
+
minimumAction: "Apply the reviewed capability configuration, then rerun the exact static or live check.",
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (options.live) {
|
|
778
|
+
await appendCompanionLiveChecks(checks, {
|
|
779
|
+
plan,
|
|
780
|
+
selected,
|
|
781
|
+
adapterCredentials,
|
|
782
|
+
fetcher,
|
|
783
|
+
sleeper,
|
|
784
|
+
allowSyntheticUnstructureUpload: options.allowSyntheticUnstructureUpload === true,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
checks.push({
|
|
789
|
+
id: "live-provider-checks",
|
|
790
|
+
category: "live-check",
|
|
791
|
+
status: "warn",
|
|
792
|
+
detail: "Live provider checks were not requested.",
|
|
793
|
+
minimumAction: `Run tiangong-ai research setup doctor --live --workspace ${root} --json after reviewing quota impact.`,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
let workspaceDoctor = null;
|
|
797
|
+
try {
|
|
798
|
+
workspaceDoctor = await doctorResearchWorkspace(root, {
|
|
799
|
+
agentSmoke: options.agentSmoke === true,
|
|
800
|
+
capabilitySmoke: options.live === true,
|
|
801
|
+
environment,
|
|
802
|
+
capabilityFetcher: fetcher,
|
|
803
|
+
});
|
|
804
|
+
checks.push({
|
|
805
|
+
id: "production-runtime",
|
|
806
|
+
category: "research-runtime",
|
|
807
|
+
status: workspaceDoctor.status === "ready" ? "pass" : "warn",
|
|
808
|
+
detail: `Workspace doctor reported ${workspaceDoctor.status}.`,
|
|
809
|
+
minimumAction: workspaceDoctor.status === "ready"
|
|
810
|
+
? null
|
|
811
|
+
: "Configure explicit production models/pricing and run the separately confirmed agent/capability smoke checks.",
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
catch (error) {
|
|
815
|
+
checks.push({
|
|
816
|
+
id: "production-runtime",
|
|
817
|
+
category: "research-runtime",
|
|
818
|
+
status: "fail",
|
|
819
|
+
detail: sanitizeResearchText(error instanceof Error ? error.message : String(error)),
|
|
820
|
+
minimumAction: "Repair workspace runtime state, then rerun setup doctor.",
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
const readiness = checks.some((check) => check.status === "fail")
|
|
824
|
+
? "BLOCKED"
|
|
825
|
+
: checks.some((check) => check.status === "warn")
|
|
826
|
+
? "PARTIALLY_READY"
|
|
827
|
+
: "READY";
|
|
828
|
+
const setupSecrets = [
|
|
829
|
+
...new Set([
|
|
830
|
+
...configuredResearchSecrets(environment),
|
|
831
|
+
...plan.credentialSources
|
|
832
|
+
.map((credential) => environment[credential.fromEnvironment])
|
|
833
|
+
.filter((value) => typeof value === "string" && value.length >= 8),
|
|
834
|
+
...adapterCredentials.values(),
|
|
835
|
+
]),
|
|
836
|
+
];
|
|
837
|
+
const report = sanitizeResearchRecord({
|
|
838
|
+
schemaVersion: 1,
|
|
839
|
+
workspace: root,
|
|
840
|
+
planSha256: plan.planSha256,
|
|
841
|
+
checkedAt: new Date().toISOString(),
|
|
842
|
+
mode: options.live ? "live" : "static",
|
|
843
|
+
readiness,
|
|
844
|
+
checks,
|
|
845
|
+
capabilityDoctor,
|
|
846
|
+
workspaceDoctor,
|
|
847
|
+
summary: {
|
|
848
|
+
pass: checks.filter((check) => check.status === "pass").length,
|
|
849
|
+
warn: checks.filter((check) => check.status === "warn").length,
|
|
850
|
+
fail: checks.filter((check) => check.status === "fail").length,
|
|
851
|
+
},
|
|
852
|
+
}, setupSecrets);
|
|
853
|
+
await writeJsonAtomic(paths.setupReport, report);
|
|
854
|
+
return report;
|
|
855
|
+
}
|
|
856
|
+
export async function retryResearchSetup(input) {
|
|
857
|
+
const root = requireAbsoluteWorkspace(resolve(input.workspace));
|
|
858
|
+
const paths = workspacePaths(root);
|
|
859
|
+
const plan = await loadAndVerifyResearchSetupPlan(paths.setupPlan);
|
|
860
|
+
const state = await loadSetupState(root, plan.planSha256);
|
|
861
|
+
if (!state.lastError || state.lastError.step !== input.step) {
|
|
862
|
+
throw setupError({
|
|
863
|
+
code: "RESEARCH_SETUP_RETRY_INVALID",
|
|
864
|
+
step: "retry",
|
|
865
|
+
reason: `Requested retry step does not match the recorded failure (${state.lastError?.step ?? "none"}).`,
|
|
866
|
+
minimumAction: "Inspect setup status and retry only the exact recorded failed step.",
|
|
867
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
868
|
+
exitCode: 2,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
if (input.clearStaleLock)
|
|
872
|
+
await clearStaleSetupLock(root);
|
|
873
|
+
await appendJournalEvent(paths.journal, "research.setup.retry.requested", "workspace", {
|
|
874
|
+
planSha256: plan.planSha256,
|
|
875
|
+
step: input.step,
|
|
876
|
+
priorErrorCode: state.lastError.code,
|
|
877
|
+
}).catch(() => undefined);
|
|
878
|
+
return applyResearchSetupPlan(paths.setupPlan, input.options);
|
|
879
|
+
}
|
|
880
|
+
export async function checkResearchSetupUpdates(workspace, environment = process.env) {
|
|
881
|
+
const root = requireAbsoluteWorkspace(resolve(workspace));
|
|
882
|
+
const plan = await loadHashVerifiedResearchSetupPlan(workspacePaths(root).setupPlan);
|
|
883
|
+
const catalog = await inspectResearchSetupCatalog({
|
|
884
|
+
selectedPath: root,
|
|
885
|
+
scope: plan.install.scope,
|
|
886
|
+
agents: plan.install.agents,
|
|
887
|
+
environment,
|
|
888
|
+
});
|
|
889
|
+
const drift = [];
|
|
890
|
+
for (const planned of plan.skills) {
|
|
891
|
+
const current = RESEARCH_SETUP_SKILLS.find((skill) => skill.id === planned.id);
|
|
892
|
+
if (!current) {
|
|
893
|
+
drift.push({ skillId: planned.id, status: "removed" });
|
|
894
|
+
}
|
|
895
|
+
else if (current.expectedTreeSha256 !== planned.expectedTreeSha256) {
|
|
896
|
+
drift.push({
|
|
897
|
+
skillId: planned.id,
|
|
898
|
+
status: "catalog-updated",
|
|
899
|
+
plannedTreeSha256: planned.expectedTreeSha256,
|
|
900
|
+
currentTreeSha256: current.expectedTreeSha256,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return {
|
|
905
|
+
schemaVersion: 1,
|
|
906
|
+
workspace: root,
|
|
907
|
+
checkedAt: new Date().toISOString(),
|
|
908
|
+
updateAvailable: drift.length > 0 || plan.cli.version !== packageVersion(),
|
|
909
|
+
cliVersionDrift: plan.cli.version === packageVersion()
|
|
910
|
+
? null
|
|
911
|
+
: { planned: plan.cli.version, active: packageVersion() },
|
|
912
|
+
drift,
|
|
913
|
+
currentInstaller: RESEARCH_SETUP_INSTALLER,
|
|
914
|
+
catalog,
|
|
915
|
+
policy: {
|
|
916
|
+
automaticUpdate: false,
|
|
917
|
+
floatingUpdate: false,
|
|
918
|
+
minimumAction: drift.length
|
|
919
|
+
? "Use the newer CLI to create and review a replacement immutable plan; upgrade never runs a floating skills update."
|
|
920
|
+
: "No catalog migration is required. Installed tree drift is reported separately by setup status/doctor.",
|
|
921
|
+
},
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
export async function clearStaleSetupLock(workspace) {
|
|
925
|
+
const root = requireAbsoluteWorkspace(resolve(workspace));
|
|
926
|
+
const lockPath = workspacePaths(root).setupLock;
|
|
927
|
+
const info = await lstat(lockPath).catch(() => undefined);
|
|
928
|
+
if (!info)
|
|
929
|
+
return;
|
|
930
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
931
|
+
throw setupError({
|
|
932
|
+
code: "RESEARCH_SETUP_LOCK_INVALID",
|
|
933
|
+
step: "retry",
|
|
934
|
+
reason: "Setup lock is not a regular file.",
|
|
935
|
+
minimumAction: "Inspect the lock path manually; it will not be removed automatically.",
|
|
936
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
937
|
+
exitCode: 3,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
let payload;
|
|
941
|
+
try {
|
|
942
|
+
payload = JSON.parse(await readFile(lockPath, "utf8"));
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
payload = null;
|
|
946
|
+
}
|
|
947
|
+
if (isObject(payload) && payload.hostname === hostname() && typeof payload.pid === "number") {
|
|
948
|
+
try {
|
|
949
|
+
process.kill(payload.pid, 0);
|
|
950
|
+
throw setupError({
|
|
951
|
+
code: "RESEARCH_SETUP_LOCK_ACTIVE",
|
|
952
|
+
step: "retry",
|
|
953
|
+
reason: `Setup lock belongs to live process ${payload.pid}.`,
|
|
954
|
+
minimumAction: "Wait for the active setup process to finish; do not clear its lock.",
|
|
955
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
956
|
+
exitCode: 3,
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
catch (error) {
|
|
960
|
+
if (error instanceof CliError)
|
|
961
|
+
throw error;
|
|
962
|
+
const code = error.code;
|
|
963
|
+
if (code !== "ESRCH")
|
|
964
|
+
throw error;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
await rm(lockPath);
|
|
968
|
+
}
|
|
969
|
+
function parseResearchSetupPlan(value) {
|
|
970
|
+
if (!isObject(value) ||
|
|
971
|
+
value.schemaVersion !== 1 ||
|
|
972
|
+
value.kind !== "tiangong-research-setup-plan" ||
|
|
973
|
+
typeof value.planId !== "string" ||
|
|
974
|
+
typeof value.createdAt !== "string" ||
|
|
975
|
+
!isObject(value.cli) ||
|
|
976
|
+
value.cli.package !== "@tiangong-ai/cli" ||
|
|
977
|
+
typeof value.cli.version !== "string" ||
|
|
978
|
+
!isObject(value.workspace) ||
|
|
979
|
+
typeof value.workspace.path !== "string" ||
|
|
980
|
+
typeof value.workspace.name !== "string" ||
|
|
981
|
+
(value.workspace.mode !== "smoke-test" && value.workspace.mode !== "production-research") ||
|
|
982
|
+
!isObject(value.install) ||
|
|
983
|
+
(value.install.scope !== "project" && value.install.scope !== "global") ||
|
|
984
|
+
value.install.mode !== "copy" ||
|
|
985
|
+
!Array.isArray(value.install.agents) ||
|
|
986
|
+
value.install.agents.some((agent) => agent !== "codex" && agent !== "claude-code") ||
|
|
987
|
+
!isObject(value.install.installer) ||
|
|
988
|
+
!Array.isArray(value.install.targets) ||
|
|
989
|
+
value.install.targets.some((target) => !isObject(target) ||
|
|
990
|
+
(target.agent !== "codex" && target.agent !== "claude-code") ||
|
|
991
|
+
typeof target.root !== "string" ||
|
|
992
|
+
!isAbsolute(target.root)) ||
|
|
993
|
+
new Set(value.install.agents).size !== value.install.agents.length ||
|
|
994
|
+
value.install.targets.length !== value.install.agents.length ||
|
|
995
|
+
!isObject(value.selection) ||
|
|
996
|
+
!validEvidenceProfile(value.selection.evidenceProfile) ||
|
|
997
|
+
!Array.isArray(value.selection.skillIds) ||
|
|
998
|
+
value.selection.skillIds.some((id) => typeof id !== "string") ||
|
|
999
|
+
!Array.isArray(value.sources) ||
|
|
1000
|
+
value.sources.some((source) => !isObject(source) ||
|
|
1001
|
+
typeof source.id !== "string" ||
|
|
1002
|
+
typeof source.repository !== "string" ||
|
|
1003
|
+
typeof source.locator !== "string" ||
|
|
1004
|
+
typeof source.immutableRef !== "string" ||
|
|
1005
|
+
!/^[0-9a-f]{40}$/.test(source.immutableRef)) ||
|
|
1006
|
+
!Array.isArray(value.skills) ||
|
|
1007
|
+
value.skills.some((skill) => !isObject(skill) ||
|
|
1008
|
+
typeof skill.id !== "string" ||
|
|
1009
|
+
typeof skill.skillName !== "string" ||
|
|
1010
|
+
typeof skill.sourceId !== "string" ||
|
|
1011
|
+
typeof skill.sourceRelativePath !== "string" ||
|
|
1012
|
+
typeof skill.expectedTreeSha256 !== "string" ||
|
|
1013
|
+
!/^[0-9a-f]{64}$/.test(skill.expectedTreeSha256) ||
|
|
1014
|
+
![
|
|
1015
|
+
"evidence-capability",
|
|
1016
|
+
"input-preprocessor",
|
|
1017
|
+
"acquisition-adapter",
|
|
1018
|
+
"post-closure-authoring",
|
|
1019
|
+
].includes(String(skill.role)) ||
|
|
1020
|
+
typeof skill.licenseId !== "string") ||
|
|
1021
|
+
!Array.isArray(value.acceptedLicenses) ||
|
|
1022
|
+
value.acceptedLicenses.some((license) => !isObject(license) ||
|
|
1023
|
+
typeof license.skillId !== "string" ||
|
|
1024
|
+
typeof license.licenseId !== "string" ||
|
|
1025
|
+
license.accepted !== true) ||
|
|
1026
|
+
!Array.isArray(value.credentialSources) ||
|
|
1027
|
+
value.credentialSources.some((credential) => !isObject(credential) ||
|
|
1028
|
+
typeof credential.id !== "string" ||
|
|
1029
|
+
typeof credential.fromEnvironment !== "string" ||
|
|
1030
|
+
(credential.storage !== "broker" && credential.storage !== "adapter")) ||
|
|
1031
|
+
!isObject(value.settings) ||
|
|
1032
|
+
Object.values(value.settings).some((item) => typeof item !== "string") ||
|
|
1033
|
+
!isObject(value.agentRoutes) ||
|
|
1034
|
+
!validAgentRoutes(value.agentRoutes) ||
|
|
1035
|
+
!isObject(value.checks) ||
|
|
1036
|
+
typeof value.checks.live !== "boolean" ||
|
|
1037
|
+
typeof value.checks.allowSyntheticUnstructureUpload !== "boolean" ||
|
|
1038
|
+
typeof value.checks.agentSmoke !== "boolean" ||
|
|
1039
|
+
(value.checks.allowSyntheticUnstructureUpload === true && value.checks.live !== true) ||
|
|
1040
|
+
!isObject(value.confirmations) ||
|
|
1041
|
+
value.confirmations.networkDownloads !== true ||
|
|
1042
|
+
typeof value.confirmations.globalMutation !== "boolean" ||
|
|
1043
|
+
typeof value.confirmations.agentSmokeCost !== "boolean" ||
|
|
1044
|
+
!Array.isArray(value.mutations) ||
|
|
1045
|
+
value.mutations.some((mutation) => !isObject(mutation) ||
|
|
1046
|
+
typeof mutation.step !== "string" ||
|
|
1047
|
+
typeof mutation.target !== "string" ||
|
|
1048
|
+
typeof mutation.reason !== "string") ||
|
|
1049
|
+
typeof value.planSha256 !== "string" ||
|
|
1050
|
+
!/^[0-9a-f]{64}$/.test(value.planSha256)) {
|
|
1051
|
+
throw setupError({
|
|
1052
|
+
code: "RESEARCH_SETUP_PLAN_INVALID",
|
|
1053
|
+
step: "plan-validation",
|
|
1054
|
+
reason: "Setup plan has an unsupported schema or field type.",
|
|
1055
|
+
minimumAction: "Create a new plan with the active CLI and review it before applying.",
|
|
1056
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
1057
|
+
exitCode: 2,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
return value;
|
|
1061
|
+
}
|
|
1062
|
+
function assertPlanMatchesCatalog(plan) {
|
|
1063
|
+
const selected = resolveSetupSkills(plan.selection.skillIds);
|
|
1064
|
+
if (canonicalJson(plan.selection.skillIds) !== canonicalJson(selected.map((skill) => skill.id))) {
|
|
1065
|
+
throw planCatalogDrift("Skill selection order or duplicates");
|
|
1066
|
+
}
|
|
1067
|
+
if (plan.workspace.path !== requireAbsoluteWorkspace(plan.workspace.path) ||
|
|
1068
|
+
plan.workspace.name !== normalizedWorkspaceName(plan.workspace.name) ||
|
|
1069
|
+
(plan.workspace.mode === "production-research" && plan.selection.evidenceProfile === "none")) {
|
|
1070
|
+
throw planCatalogDrift("workspace identity or mode");
|
|
1071
|
+
}
|
|
1072
|
+
validateEvidenceProfileSelection(plan.selection.evidenceProfile, selected);
|
|
1073
|
+
if (canonicalJson(plan.install.installer) !== canonicalJson(RESEARCH_SETUP_INSTALLER)) {
|
|
1074
|
+
throw planCatalogDrift("installer identity");
|
|
1075
|
+
}
|
|
1076
|
+
const expectedSources = [...new Set(selected.map((skill) => skill.sourceId))]
|
|
1077
|
+
.map(setupSource)
|
|
1078
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
1079
|
+
.map((source) => ({
|
|
1080
|
+
id: source.id,
|
|
1081
|
+
repository: source.repository,
|
|
1082
|
+
locator: source.locator,
|
|
1083
|
+
immutableRef: source.immutableRef,
|
|
1084
|
+
}));
|
|
1085
|
+
if (canonicalJson(plan.sources) !== canonicalJson(expectedSources)) {
|
|
1086
|
+
throw planCatalogDrift("source pins");
|
|
1087
|
+
}
|
|
1088
|
+
const expectedSkills = selected.map((skill) => ({
|
|
1089
|
+
id: skill.id,
|
|
1090
|
+
skillName: skill.skillName,
|
|
1091
|
+
sourceId: skill.sourceId,
|
|
1092
|
+
sourceRelativePath: skill.sourceRelativePath,
|
|
1093
|
+
expectedTreeSha256: skill.expectedTreeSha256,
|
|
1094
|
+
role: skill.role,
|
|
1095
|
+
licenseId: skill.license.id,
|
|
1096
|
+
}));
|
|
1097
|
+
if (canonicalJson(plan.skills) !== canonicalJson(expectedSkills)) {
|
|
1098
|
+
throw planCatalogDrift("Skill identities or tree hashes");
|
|
1099
|
+
}
|
|
1100
|
+
const expectedLicenses = selected.map((skill) => ({
|
|
1101
|
+
skillId: skill.id,
|
|
1102
|
+
licenseId: skill.license.id,
|
|
1103
|
+
accepted: true,
|
|
1104
|
+
}));
|
|
1105
|
+
if (canonicalJson(plan.acceptedLicenses) !== canonicalJson(expectedLicenses)) {
|
|
1106
|
+
throw planCatalogDrift("license acceptance bindings");
|
|
1107
|
+
}
|
|
1108
|
+
const expectedSettings = normalizedSettings(selected, plan.settings);
|
|
1109
|
+
if (canonicalJson(plan.settings) !== canonicalJson(expectedSettings)) {
|
|
1110
|
+
throw planCatalogDrift("settings");
|
|
1111
|
+
}
|
|
1112
|
+
const expectedCredentialSources = normalizedCredentialSources(selected, Object.fromEntries(plan.credentialSources.map((item) => [item.id, item.fromEnvironment])));
|
|
1113
|
+
if (canonicalJson(plan.credentialSources) !== canonicalJson(expectedCredentialSources)) {
|
|
1114
|
+
throw planCatalogDrift("credential source bindings");
|
|
1115
|
+
}
|
|
1116
|
+
if (canonicalJson(plan.agentRoutes) !== canonicalJson(normalizeAgentRoutes(plan.agentRoutes))) {
|
|
1117
|
+
throw planCatalogDrift("agent routes");
|
|
1118
|
+
}
|
|
1119
|
+
if (canonicalJson(plan.install.agents) !== canonicalJson(normalizeAgents(plan.install.agents))) {
|
|
1120
|
+
throw planCatalogDrift("agent selection order or duplicates");
|
|
1121
|
+
}
|
|
1122
|
+
const expectedTargets = plannedInstallTargets(plan.workspace.path, plan.install.scope, plan.install.agents, process.env, Object.fromEntries(plan.install.targets.map((target) => [target.agent, target.root])));
|
|
1123
|
+
if (canonicalJson(plan.install.targets) !== canonicalJson(expectedTargets)) {
|
|
1124
|
+
throw planCatalogDrift("install targets");
|
|
1125
|
+
}
|
|
1126
|
+
if ((plan.install.scope === "global") !== plan.confirmations.globalMutation) {
|
|
1127
|
+
throw planCatalogDrift("global mutation confirmation");
|
|
1128
|
+
}
|
|
1129
|
+
if (plan.checks.agentSmoke !== plan.confirmations.agentSmokeCost) {
|
|
1130
|
+
throw planCatalogDrift("agent smoke confirmation");
|
|
1131
|
+
}
|
|
1132
|
+
const expectedMutations = setupMutations(plan.workspace.path, plan.install.targets, selected, plan.credentialSources);
|
|
1133
|
+
if (canonicalJson(plan.mutations) !== canonicalJson(expectedMutations)) {
|
|
1134
|
+
throw planCatalogDrift("declared mutations");
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
function validAgentRoutes(value) {
|
|
1138
|
+
const allowed = new Set(["producerModel", "reviewerModel", "producerPricing", "reviewerPricing"]);
|
|
1139
|
+
if (Object.keys(value).some((key) => !allowed.has(key)))
|
|
1140
|
+
return false;
|
|
1141
|
+
if (!(value.producerModel === null || typeof value.producerModel === "string") ||
|
|
1142
|
+
!(value.reviewerModel === null || typeof value.reviewerModel === "string")) {
|
|
1143
|
+
return false;
|
|
1144
|
+
}
|
|
1145
|
+
return validNullablePricing(value.producerPricing) && validNullablePricing(value.reviewerPricing);
|
|
1146
|
+
}
|
|
1147
|
+
function validNullablePricing(value) {
|
|
1148
|
+
if (value === null)
|
|
1149
|
+
return true;
|
|
1150
|
+
if (!isObject(value))
|
|
1151
|
+
return false;
|
|
1152
|
+
const keys = [
|
|
1153
|
+
"inputUsdPerMillionTokens",
|
|
1154
|
+
"cachedInputUsdPerMillionTokens",
|
|
1155
|
+
"outputUsdPerMillionTokens",
|
|
1156
|
+
];
|
|
1157
|
+
return (Object.keys(value).length === keys.length &&
|
|
1158
|
+
keys.every((key) => typeof value[key] === "number" &&
|
|
1159
|
+
Number.isFinite(value[key]) &&
|
|
1160
|
+
value[key] >= 0));
|
|
1161
|
+
}
|
|
1162
|
+
function validateEvidenceProfileSelection(profile, selected) {
|
|
1163
|
+
const actual = selected
|
|
1164
|
+
.filter((skill) => skill.sourceId === "brave-search-skills")
|
|
1165
|
+
.map((skill) => skill.id)
|
|
1166
|
+
.sort();
|
|
1167
|
+
const expected = [...BRAVE_PROFILE_SKILLS[profile]].sort();
|
|
1168
|
+
if (canonicalJson(actual) !== canonicalJson(expected)) {
|
|
1169
|
+
throw setupError({
|
|
1170
|
+
code: "RESEARCH_SETUP_SELECTION_INVALID",
|
|
1171
|
+
step: "selection",
|
|
1172
|
+
reason: `Brave evidence selection does not exactly match profile ${profile}.`,
|
|
1173
|
+
minimumAction: "Choose one named evidence profile; do not construct a silent partial provider fallback.",
|
|
1174
|
+
retryCommand: "tiangong-ai research setup catalog --json",
|
|
1175
|
+
exitCode: 2,
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
function validateLicenseAcceptances(selected, acceptedLicenseIds) {
|
|
1180
|
+
const accepted = new Set(acceptedLicenseIds);
|
|
1181
|
+
const missing = selected.filter((skill) => !accepted.has(skill.license.id));
|
|
1182
|
+
if (missing.length) {
|
|
1183
|
+
throw setupError({
|
|
1184
|
+
code: "RESEARCH_SETUP_LICENSE_NOT_ACCEPTED",
|
|
1185
|
+
step: "license",
|
|
1186
|
+
reason: `Explicit license review is missing for: ${missing.map((skill) => skill.id).join(", ")}.`,
|
|
1187
|
+
minimumAction: "Review each pinned license URL and explicitly accept only the Skills you choose to install.",
|
|
1188
|
+
retryCommand: "tiangong-ai research setup catalog --json",
|
|
1189
|
+
exitCode: 2,
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
function normalizedSettings(selected, supplied) {
|
|
1194
|
+
const definitions = requiredSettingsForSkills(selected);
|
|
1195
|
+
const allowed = new Set(definitions.map((setting) => setting.id));
|
|
1196
|
+
const unknown = Object.keys(supplied).filter((id) => !allowed.has(id));
|
|
1197
|
+
if (unknown.length) {
|
|
1198
|
+
throw setupError({
|
|
1199
|
+
code: "RESEARCH_SETUP_SETTING_INVALID",
|
|
1200
|
+
step: "configuration",
|
|
1201
|
+
reason: `Settings are not declared by selected Skills: ${unknown.join(", ")}.`,
|
|
1202
|
+
minimumAction: "Use only the setting IDs reported by setup catalog.",
|
|
1203
|
+
retryCommand: "tiangong-ai research setup catalog --json",
|
|
1204
|
+
exitCode: 2,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
const normalized = {};
|
|
1208
|
+
for (const setting of definitions) {
|
|
1209
|
+
const candidate = supplied[setting.id]?.trim() || setting.defaultValue;
|
|
1210
|
+
if (!candidate) {
|
|
1211
|
+
if (setting.required) {
|
|
1212
|
+
throw setupError({
|
|
1213
|
+
code: "RESEARCH_SETUP_SETTING_REQUIRED",
|
|
1214
|
+
step: "configuration",
|
|
1215
|
+
reason: `Required non-secret setting is missing: ${setting.id}.`,
|
|
1216
|
+
minimumAction: `Provide ${setting.id} in the reviewed setup settings object.`,
|
|
1217
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
1218
|
+
exitCode: 2,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
validateSetupSetting(setting.id, setting.validation, candidate);
|
|
1224
|
+
normalized[setting.id] = candidate;
|
|
1225
|
+
}
|
|
1226
|
+
return Object.fromEntries(Object.entries(normalized).sort(([left], [right]) => left.localeCompare(right)));
|
|
1227
|
+
}
|
|
1228
|
+
function normalizedCredentialSources(selected, supplied) {
|
|
1229
|
+
const definitions = credentialDefinitionsForSkills(selected);
|
|
1230
|
+
const allowed = new Map(definitions.map((credential) => [credential.id, credential]));
|
|
1231
|
+
const unknown = Object.keys(supplied).filter((id) => !allowed.has(id));
|
|
1232
|
+
if (unknown.length) {
|
|
1233
|
+
throw setupError({
|
|
1234
|
+
code: "RESEARCH_SETUP_CREDENTIAL_INVALID",
|
|
1235
|
+
step: "credentials",
|
|
1236
|
+
reason: `Credential IDs are not declared by selected Skills: ${unknown.join(", ")}.`,
|
|
1237
|
+
minimumAction: "Use only credential IDs reported by setup catalog.",
|
|
1238
|
+
retryCommand: "tiangong-ai research setup catalog --json",
|
|
1239
|
+
exitCode: 2,
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
return Object.entries(supplied)
|
|
1243
|
+
.map(([id, fromEnvironment]) => {
|
|
1244
|
+
assertEnvironmentName(fromEnvironment);
|
|
1245
|
+
return { id, fromEnvironment, storage: allowed.get(id).storage };
|
|
1246
|
+
})
|
|
1247
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
1248
|
+
}
|
|
1249
|
+
function normalizeAgentRoutes(value) {
|
|
1250
|
+
return {
|
|
1251
|
+
producerModel: normalizeNullableIdentifier(value?.producerModel),
|
|
1252
|
+
reviewerModel: normalizeNullableIdentifier(value?.reviewerModel),
|
|
1253
|
+
producerPricing: normalizePricing(value?.producerPricing),
|
|
1254
|
+
reviewerPricing: normalizePricing(value?.reviewerPricing),
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
function normalizePricing(value) {
|
|
1258
|
+
if (value === undefined || value === null)
|
|
1259
|
+
return null;
|
|
1260
|
+
const numbers = [
|
|
1261
|
+
value.inputUsdPerMillionTokens,
|
|
1262
|
+
value.cachedInputUsdPerMillionTokens,
|
|
1263
|
+
value.outputUsdPerMillionTokens,
|
|
1264
|
+
];
|
|
1265
|
+
if (numbers.some((item) => typeof item !== "number" || !Number.isFinite(item) || item < 0)) {
|
|
1266
|
+
throw setupError({
|
|
1267
|
+
code: "RESEARCH_SETUP_AGENT_ROUTE_INVALID",
|
|
1268
|
+
step: "agent-route",
|
|
1269
|
+
reason: "Agent pricing must use finite non-negative USD-per-million-token values.",
|
|
1270
|
+
minimumAction: "Provide current reviewed provider pricing or defer production readiness.",
|
|
1271
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
1272
|
+
exitCode: 2,
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
return value;
|
|
1276
|
+
}
|
|
1277
|
+
function normalizeNullableIdentifier(value) {
|
|
1278
|
+
if (value === undefined || value === null || !value.trim())
|
|
1279
|
+
return null;
|
|
1280
|
+
const normalized = value.trim();
|
|
1281
|
+
if (normalized.length > 200 || /[\r\n\0]/.test(normalized)) {
|
|
1282
|
+
throw setupError({
|
|
1283
|
+
code: "RESEARCH_SETUP_AGENT_ROUTE_INVALID",
|
|
1284
|
+
step: "agent-route",
|
|
1285
|
+
reason: "Agent model identifier is malformed.",
|
|
1286
|
+
minimumAction: "Use an exact provider model identifier without control characters.",
|
|
1287
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
1288
|
+
exitCode: 2,
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
return normalized;
|
|
1292
|
+
}
|
|
1293
|
+
function normalizeAgents(values) {
|
|
1294
|
+
const agents = [...new Set(values)];
|
|
1295
|
+
if (agents.length === 0 || agents.some((agent) => agent !== "codex" && agent !== "claude-code")) {
|
|
1296
|
+
throw setupError({
|
|
1297
|
+
code: "RESEARCH_SETUP_AGENT_INVALID",
|
|
1298
|
+
step: "selection",
|
|
1299
|
+
reason: "Setup agents must be an explicit non-empty subset of codex and claude-code.",
|
|
1300
|
+
minimumAction: "Choose codex, claude-code, or both.",
|
|
1301
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
1302
|
+
exitCode: 2,
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
return agents.sort();
|
|
1306
|
+
}
|
|
1307
|
+
function plannedInstallTargets(workspace, scope, agents, environment, overrides) {
|
|
1308
|
+
const unknownOverride = Object.keys(overrides ?? {}).find((agent) => agent !== "codex" && agent !== "claude-code");
|
|
1309
|
+
if (unknownOverride) {
|
|
1310
|
+
throw setupError({
|
|
1311
|
+
code: "RESEARCH_SETUP_TARGET_INVALID",
|
|
1312
|
+
step: "selection",
|
|
1313
|
+
reason: `Unknown install target agent: ${unknownOverride}.`,
|
|
1314
|
+
minimumAction: "Use only the target roots bound to codex or claude-code.",
|
|
1315
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
1316
|
+
exitCode: 2,
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
const canonicalWorkspace = resolve(workspace);
|
|
1320
|
+
const targets = agents.map((agent) => {
|
|
1321
|
+
const supplied = overrides?.[agent];
|
|
1322
|
+
const target = resolve(supplied ?? setupTargetRoot({ workspace, scope, agent, environment }));
|
|
1323
|
+
const globalBase = agent === "codex" ? dirname(dirname(target)) : dirname(target);
|
|
1324
|
+
const installerCompatibleGlobalTarget = scope !== "global" ||
|
|
1325
|
+
(globalBase !== dirname(globalBase) &&
|
|
1326
|
+
target ===
|
|
1327
|
+
(agent === "codex" ? join(globalBase, ".agents", "skills") : join(globalBase, "skills")));
|
|
1328
|
+
if (!isAbsolute(target) ||
|
|
1329
|
+
target === resolve(target, sep) ||
|
|
1330
|
+
/[\0\r\n]/.test(target) ||
|
|
1331
|
+
(scope === "project" && target !== setupTargetRoot({ workspace, scope: "project", agent })) ||
|
|
1332
|
+
!installerCompatibleGlobalTarget ||
|
|
1333
|
+
(scope === "global" &&
|
|
1334
|
+
(target === canonicalWorkspace || target.startsWith(`${canonicalWorkspace}${sep}`)))) {
|
|
1335
|
+
throw setupError({
|
|
1336
|
+
code: "RESEARCH_SETUP_TARGET_INVALID",
|
|
1337
|
+
step: "selection",
|
|
1338
|
+
reason: `Install target is incompatible with ${scope} scope for ${agent}.`,
|
|
1339
|
+
minimumAction: "Use project scope for workspace-local copies, or an explicit global agent home outside the workspace.",
|
|
1340
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
1341
|
+
exitCode: 2,
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
return { agent, root: target };
|
|
1345
|
+
});
|
|
1346
|
+
if (new Set(targets.map((target) => target.root)).size !== targets.length) {
|
|
1347
|
+
throw setupError({
|
|
1348
|
+
code: "RESEARCH_SETUP_TARGET_CONFLICT",
|
|
1349
|
+
step: "selection",
|
|
1350
|
+
reason: "Multiple selected agents resolve to the same install root.",
|
|
1351
|
+
minimumAction: "Choose distinct agent homes or install for only one agent.",
|
|
1352
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
1353
|
+
exitCode: 2,
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
return targets;
|
|
1357
|
+
}
|
|
1358
|
+
function plannedTargetRoot(plan, agent) {
|
|
1359
|
+
const target = plan.install.targets.find((candidate) => candidate.agent === agent);
|
|
1360
|
+
if (!target)
|
|
1361
|
+
throw planCatalogDrift(`missing install target for ${agent}`);
|
|
1362
|
+
return target.root;
|
|
1363
|
+
}
|
|
1364
|
+
function validEvidenceProfile(value) {
|
|
1365
|
+
return Object.hasOwn(BRAVE_PROFILE_SKILLS, String(value));
|
|
1366
|
+
}
|
|
1367
|
+
function planCatalogDrift(label) {
|
|
1368
|
+
return setupError({
|
|
1369
|
+
code: "RESEARCH_SETUP_PLAN_CATALOG_DRIFT",
|
|
1370
|
+
step: "plan-validation",
|
|
1371
|
+
reason: `Setup plan ${label} does not match the active immutable catalog.`,
|
|
1372
|
+
minimumAction: "Generate and review a replacement plan; the CLI will not reinterpret an old plan.",
|
|
1373
|
+
retryCommand: "tiangong-ai research setup update --check --json",
|
|
1374
|
+
exitCode: 3,
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
async function ensureSetupWorkspace(plan) {
|
|
1378
|
+
const context = await inspectResearchContext(plan.workspace.path);
|
|
1379
|
+
if (context.role === "workspace") {
|
|
1380
|
+
const config = await loadWorkspaceConfig(plan.workspace.path);
|
|
1381
|
+
if (config.mode !== plan.workspace.mode) {
|
|
1382
|
+
throw setupError({
|
|
1383
|
+
code: "RESEARCH_SETUP_WORKSPACE_CONFLICT",
|
|
1384
|
+
step: "workspace",
|
|
1385
|
+
reason: `Existing workspace mode ${config.mode} differs from plan mode ${plan.workspace.mode}.`,
|
|
1386
|
+
minimumAction: "Create a new reviewed plan for the existing mode or choose a different empty directory.",
|
|
1387
|
+
retryCommand: `tiangong-ai research setup status --workspace ${plan.workspace.path} --json`,
|
|
1388
|
+
exitCode: 3,
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
if (context.role !== "setup" && context.role !== "unmanaged") {
|
|
1394
|
+
throw setupError({
|
|
1395
|
+
code: "RESEARCH_SETUP_WORKSPACE_INVALID",
|
|
1396
|
+
step: "workspace",
|
|
1397
|
+
reason: `Target context is ${context.role}.`,
|
|
1398
|
+
minimumAction: "Use an empty regular directory or repair the reported partial workspace state.",
|
|
1399
|
+
retryCommand: `tiangong-ai research context inspect --path ${plan.workspace.path} --json`,
|
|
1400
|
+
exitCode: 3,
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
await initializeResearchWorkspace(plan.workspace.path, plan.workspace.name, plan.workspace.mode);
|
|
1404
|
+
}
|
|
1405
|
+
async function configureAgentRoutes(plan) {
|
|
1406
|
+
if (!plan.agentRoutes.producerModel &&
|
|
1407
|
+
!plan.agentRoutes.reviewerModel &&
|
|
1408
|
+
!plan.agentRoutes.producerPricing &&
|
|
1409
|
+
!plan.agentRoutes.reviewerPricing) {
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
const paths = workspacePaths(plan.workspace.path);
|
|
1413
|
+
const config = await loadWorkspaceConfig(plan.workspace.path);
|
|
1414
|
+
const updated = {
|
|
1415
|
+
...config,
|
|
1416
|
+
producer: {
|
|
1417
|
+
...config.producer,
|
|
1418
|
+
...(plan.agentRoutes.producerModel === null ? {} : { model: plan.agentRoutes.producerModel }),
|
|
1419
|
+
...(plan.agentRoutes.producerPricing === null
|
|
1420
|
+
? {}
|
|
1421
|
+
: { pricing: plan.agentRoutes.producerPricing }),
|
|
1422
|
+
},
|
|
1423
|
+
reviewer: {
|
|
1424
|
+
...config.reviewer,
|
|
1425
|
+
...(plan.agentRoutes.reviewerModel === null ? {} : { model: plan.agentRoutes.reviewerModel }),
|
|
1426
|
+
...(plan.agentRoutes.reviewerPricing === null
|
|
1427
|
+
? {}
|
|
1428
|
+
: { pricing: plan.agentRoutes.reviewerPricing }),
|
|
1429
|
+
},
|
|
1430
|
+
};
|
|
1431
|
+
await writeJsonAtomic(paths.config, updated);
|
|
1432
|
+
}
|
|
1433
|
+
async function inspectSelectedInstallations(plan, selected, _environment) {
|
|
1434
|
+
const results = [];
|
|
1435
|
+
for (const agent of plan.install.agents) {
|
|
1436
|
+
const root = plannedTargetRoot(plan, agent);
|
|
1437
|
+
const boundary = plan.install.scope === "project"
|
|
1438
|
+
? plan.workspace.path
|
|
1439
|
+
: agent === "codex"
|
|
1440
|
+
? dirname(dirname(dirname(root)))
|
|
1441
|
+
: dirname(dirname(root));
|
|
1442
|
+
await assertNoSymlinkedExistingPath(root, boundary);
|
|
1443
|
+
for (const skill of selected) {
|
|
1444
|
+
const path = join(root, skill.skillName);
|
|
1445
|
+
if (!(await pathExists(path))) {
|
|
1446
|
+
results.push({
|
|
1447
|
+
skillId: skill.id,
|
|
1448
|
+
skillName: skill.skillName,
|
|
1449
|
+
agent,
|
|
1450
|
+
path,
|
|
1451
|
+
status: "missing",
|
|
1452
|
+
observedTreeSha256: null,
|
|
1453
|
+
detail: "Skill is not installed.",
|
|
1454
|
+
});
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
try {
|
|
1458
|
+
const info = await lstat(path);
|
|
1459
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
1460
|
+
results.push({
|
|
1461
|
+
skillId: skill.id,
|
|
1462
|
+
skillName: skill.skillName,
|
|
1463
|
+
agent,
|
|
1464
|
+
path,
|
|
1465
|
+
status: "blocked",
|
|
1466
|
+
observedTreeSha256: null,
|
|
1467
|
+
detail: "Install destination is not a regular non-symlink directory.",
|
|
1468
|
+
});
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1471
|
+
const observedTreeSha256 = await hashRegularTree(path);
|
|
1472
|
+
results.push({
|
|
1473
|
+
skillId: skill.id,
|
|
1474
|
+
skillName: skill.skillName,
|
|
1475
|
+
agent,
|
|
1476
|
+
path,
|
|
1477
|
+
status: observedTreeSha256 === skill.expectedTreeSha256
|
|
1478
|
+
? "installed"
|
|
1479
|
+
: "drifted",
|
|
1480
|
+
observedTreeSha256,
|
|
1481
|
+
detail: observedTreeSha256 === skill.expectedTreeSha256
|
|
1482
|
+
? "Installed bytes match the reviewed tree hash."
|
|
1483
|
+
: "Installed bytes differ from the reviewed tree hash.",
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
catch (error) {
|
|
1487
|
+
results.push({
|
|
1488
|
+
skillId: skill.id,
|
|
1489
|
+
skillName: skill.skillName,
|
|
1490
|
+
agent,
|
|
1491
|
+
path,
|
|
1492
|
+
status: "blocked",
|
|
1493
|
+
observedTreeSha256: null,
|
|
1494
|
+
detail: sanitizeResearchText(error instanceof Error ? error.message : String(error)),
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
return results;
|
|
1500
|
+
}
|
|
1501
|
+
async function verifyInstallerPackage(runner, cwd, environment) {
|
|
1502
|
+
const result = await runner({
|
|
1503
|
+
command: "npm",
|
|
1504
|
+
args: [
|
|
1505
|
+
"view",
|
|
1506
|
+
`skills@${RESEARCH_SETUP_INSTALLER.version}`,
|
|
1507
|
+
"version",
|
|
1508
|
+
"dist.integrity",
|
|
1509
|
+
"gitHead",
|
|
1510
|
+
"--json",
|
|
1511
|
+
],
|
|
1512
|
+
cwd,
|
|
1513
|
+
environment,
|
|
1514
|
+
timeoutMs: 60_000,
|
|
1515
|
+
});
|
|
1516
|
+
if (result.exitCode !== 0) {
|
|
1517
|
+
throw commandFailure("installer-verification", "npm", result, cwd, environment);
|
|
1518
|
+
}
|
|
1519
|
+
let value;
|
|
1520
|
+
try {
|
|
1521
|
+
value = JSON.parse(result.stdout);
|
|
1522
|
+
}
|
|
1523
|
+
catch {
|
|
1524
|
+
value = null;
|
|
1525
|
+
}
|
|
1526
|
+
if (!isObject(value) ||
|
|
1527
|
+
value.version !== RESEARCH_SETUP_INSTALLER.version ||
|
|
1528
|
+
value["dist.integrity"] !== RESEARCH_SETUP_INSTALLER.npmIntegrity ||
|
|
1529
|
+
value.gitHead !== RESEARCH_SETUP_INSTALLER.gitHead) {
|
|
1530
|
+
throw setupError({
|
|
1531
|
+
code: "RESEARCH_SETUP_INSTALLER_INTEGRITY_FAILED",
|
|
1532
|
+
step: "installer-verification",
|
|
1533
|
+
reason: "npm registry metadata did not match the pinned installer version and integrity.",
|
|
1534
|
+
minimumAction: "Stop and inspect the registry/source metadata; do not bypass installer verification.",
|
|
1535
|
+
retryCommand: `npm view skills@${RESEARCH_SETUP_INSTALLER.version} version dist.integrity gitHead --json`,
|
|
1536
|
+
exitCode: 3,
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
async function ensureSetupSourceCheckout(plan, sourceId, runner, environment) {
|
|
1541
|
+
const source = plan.sources.find((candidate) => candidate.id === sourceId);
|
|
1542
|
+
if (!source)
|
|
1543
|
+
throw planCatalogDrift(`missing source ${sourceId}`);
|
|
1544
|
+
const checkout = join(workspacePaths(plan.workspace.path).setupSources, `${source.id}-${source.immutableRef.slice(0, 12)}`);
|
|
1545
|
+
await assertNoSymlinkedExistingPath(dirname(checkout), plan.workspace.path);
|
|
1546
|
+
if (!(await pathExists(checkout))) {
|
|
1547
|
+
await ensureDirectory(workspacePaths(plan.workspace.path).setupSources);
|
|
1548
|
+
await runChecked(runner, "git", ["init", "--quiet", checkout], plan.workspace.path, environment, "source-checkout");
|
|
1549
|
+
await runChecked(runner, "git", ["-C", checkout, "remote", "add", "origin", source.locator], plan.workspace.path, environment, "source-checkout");
|
|
1550
|
+
}
|
|
1551
|
+
const info = await lstat(checkout);
|
|
1552
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
1553
|
+
throw setupError({
|
|
1554
|
+
code: "RESEARCH_SETUP_SOURCE_INVALID",
|
|
1555
|
+
step: "source-checkout",
|
|
1556
|
+
reason: `Source checkout is not a regular non-symlink directory: ${source.id}.`,
|
|
1557
|
+
minimumAction: "Inspect the source cache; setup will not replace or follow it.",
|
|
1558
|
+
retryCommand: `tiangong-ai research setup status --workspace ${plan.workspace.path} --json`,
|
|
1559
|
+
exitCode: 3,
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
let origin = await runner({
|
|
1563
|
+
command: "git",
|
|
1564
|
+
args: ["-C", checkout, "remote", "get-url", "origin"],
|
|
1565
|
+
cwd: plan.workspace.path,
|
|
1566
|
+
environment,
|
|
1567
|
+
timeoutMs: 30_000,
|
|
1568
|
+
});
|
|
1569
|
+
if (origin.exitCode !== 0) {
|
|
1570
|
+
const incompleteHead = await runner({
|
|
1571
|
+
command: "git",
|
|
1572
|
+
args: ["-C", checkout, "rev-parse", "HEAD"],
|
|
1573
|
+
cwd: plan.workspace.path,
|
|
1574
|
+
environment,
|
|
1575
|
+
timeoutMs: 30_000,
|
|
1576
|
+
});
|
|
1577
|
+
if (incompleteHead.exitCode === 0) {
|
|
1578
|
+
throw setupError({
|
|
1579
|
+
code: "RESEARCH_SETUP_SOURCE_DRIFT",
|
|
1580
|
+
step: "source-verification",
|
|
1581
|
+
reason: `Existing source checkout has a commit but no reviewed origin: ${source.id}.`,
|
|
1582
|
+
minimumAction: "Use a new empty source cache path; the CLI will not adopt this checkout.",
|
|
1583
|
+
retryCommand: `tiangong-ai research setup status --workspace ${plan.workspace.path} --json`,
|
|
1584
|
+
exitCode: 3,
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
await runChecked(runner, "git", ["-C", checkout, "remote", "add", "origin", source.locator], plan.workspace.path, environment, "source-checkout");
|
|
1588
|
+
origin = await runChecked(runner, "git", ["-C", checkout, "remote", "get-url", "origin"], plan.workspace.path, environment, "source-verification");
|
|
1589
|
+
}
|
|
1590
|
+
if (origin.stdout.trim() !== source.locator) {
|
|
1591
|
+
throw setupError({
|
|
1592
|
+
code: "RESEARCH_SETUP_SOURCE_DRIFT",
|
|
1593
|
+
step: "source-verification",
|
|
1594
|
+
reason: `Source checkout identity differs from the reviewed plan: ${source.id}.`,
|
|
1595
|
+
minimumAction: "Use a new empty source cache path; do not update or rewrite the existing checkout in place.",
|
|
1596
|
+
retryCommand: `tiangong-ai research setup status --workspace ${plan.workspace.path} --json`,
|
|
1597
|
+
exitCode: 3,
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
let head = await runner({
|
|
1601
|
+
command: "git",
|
|
1602
|
+
args: ["-C", checkout, "rev-parse", "HEAD"],
|
|
1603
|
+
cwd: plan.workspace.path,
|
|
1604
|
+
environment,
|
|
1605
|
+
timeoutMs: 30_000,
|
|
1606
|
+
});
|
|
1607
|
+
if (head.exitCode !== 0) {
|
|
1608
|
+
// A process may have been interrupted after git init/remote-add. Resume only
|
|
1609
|
+
// that exact incomplete checkout; never rewrite a checkout with a valid,
|
|
1610
|
+
// different HEAD.
|
|
1611
|
+
await runChecked(runner, "git", ["-C", checkout, "fetch", "--depth", "1", "origin", source.immutableRef], plan.workspace.path, environment, "source-checkout", 180_000);
|
|
1612
|
+
await runChecked(runner, "git", ["-C", checkout, "checkout", "--quiet", "--detach", "FETCH_HEAD"], plan.workspace.path, environment, "source-checkout");
|
|
1613
|
+
head = await runChecked(runner, "git", ["-C", checkout, "rev-parse", "HEAD"], plan.workspace.path, environment, "source-verification");
|
|
1614
|
+
}
|
|
1615
|
+
if (head.stdout.trim().toLowerCase() !== source.immutableRef) {
|
|
1616
|
+
throw setupError({
|
|
1617
|
+
code: "RESEARCH_SETUP_SOURCE_DRIFT",
|
|
1618
|
+
step: "source-verification",
|
|
1619
|
+
reason: `Source checkout identity differs from the reviewed plan: ${source.id}.`,
|
|
1620
|
+
minimumAction: "Use a new empty source cache path; do not update or rewrite the existing checkout in place.",
|
|
1621
|
+
retryCommand: `tiangong-ai research setup status --workspace ${plan.workspace.path} --json`,
|
|
1622
|
+
exitCode: 3,
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
for (const skill of plan.skills.filter((candidate) => candidate.sourceId === source.id)) {
|
|
1626
|
+
const sourcePath = resolve(checkout, skill.sourceRelativePath);
|
|
1627
|
+
if (!sourcePath.startsWith(`${resolve(checkout)}${sep}`))
|
|
1628
|
+
throw planCatalogDrift("source path");
|
|
1629
|
+
const observedTreeSha256 = await hashRegularTree(sourcePath);
|
|
1630
|
+
if (observedTreeSha256 !== skill.expectedTreeSha256) {
|
|
1631
|
+
throw setupError({
|
|
1632
|
+
code: "RESEARCH_SETUP_SOURCE_HASH_MISMATCH",
|
|
1633
|
+
step: "source-verification",
|
|
1634
|
+
reason: `Pinned source bytes failed the reviewed tree hash for ${skill.id}.`,
|
|
1635
|
+
minimumAction: "Stop and inspect the immutable source; do not install mismatched bytes.",
|
|
1636
|
+
retryCommand: `tiangong-ai research setup update --check --workspace ${plan.workspace.path} --json`,
|
|
1637
|
+
exitCode: 3,
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
return checkout;
|
|
1642
|
+
}
|
|
1643
|
+
async function installSetupSkills(input) {
|
|
1644
|
+
if (!input.skills.length)
|
|
1645
|
+
return;
|
|
1646
|
+
const args = [
|
|
1647
|
+
"--yes",
|
|
1648
|
+
`skills@${RESEARCH_SETUP_INSTALLER.version}`,
|
|
1649
|
+
"add",
|
|
1650
|
+
input.sourceDirectory,
|
|
1651
|
+
"--skill",
|
|
1652
|
+
...input.skills.map((skill) => skill.skillName),
|
|
1653
|
+
"--agent",
|
|
1654
|
+
input.agent,
|
|
1655
|
+
"--yes",
|
|
1656
|
+
"--copy",
|
|
1657
|
+
...(input.plan.install.scope === "global" ? ["--global"] : []),
|
|
1658
|
+
];
|
|
1659
|
+
const result = await input.runner({
|
|
1660
|
+
command: "npx",
|
|
1661
|
+
args,
|
|
1662
|
+
cwd: input.plan.workspace.path,
|
|
1663
|
+
environment: input.environment,
|
|
1664
|
+
timeoutMs: 5 * 60_000,
|
|
1665
|
+
});
|
|
1666
|
+
if (result.exitCode !== 0) {
|
|
1667
|
+
throw commandFailure("skill-install", "npx", result, input.plan.workspace.path, input.environment);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
async function configureSelectedCapabilities(plan, _environment) {
|
|
1671
|
+
if (plan.selection.evidenceProfile === "none" &&
|
|
1672
|
+
!plan.selection.skillIds.includes("tiangong.kb-sci-search")) {
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
const codexRoot = plannedTargetRoot(plan, "codex");
|
|
1676
|
+
if (plan.selection.evidenceProfile !== "none") {
|
|
1677
|
+
await configureExternalSkillProfile({
|
|
1678
|
+
workspace: plan.workspace.path,
|
|
1679
|
+
profile: plan.selection.evidenceProfile,
|
|
1680
|
+
skillRoot: codexRoot,
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
if (plan.selection.skillIds.includes("tiangong.kb-sci-search")) {
|
|
1684
|
+
const skill = setupSkill("tiangong.kb-sci-search");
|
|
1685
|
+
const source = setupSource(skill.sourceId);
|
|
1686
|
+
await configureTiangongSciCapability({
|
|
1687
|
+
workspace: plan.workspace.path,
|
|
1688
|
+
skillPath: join(codexRoot, skill.skillName),
|
|
1689
|
+
source: {
|
|
1690
|
+
type: "git",
|
|
1691
|
+
locator: source.locator,
|
|
1692
|
+
immutableRef: source.immutableRef,
|
|
1693
|
+
expectedTreeSha256: skill.expectedTreeSha256,
|
|
1694
|
+
license: "MIT",
|
|
1695
|
+
catalogId: "first-party.tiangong.kb-sci-search",
|
|
1696
|
+
},
|
|
1697
|
+
endpoint: plan.settings["tiangong.sci.endpoint"],
|
|
1698
|
+
...(plan.settings["tiangong.sci.region"] === undefined
|
|
1699
|
+
? {}
|
|
1700
|
+
: { region: plan.settings["tiangong.sci.region"] }),
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
async function configurePlanCredentials(plan, environment) {
|
|
1705
|
+
for (const credential of plan.credentialSources) {
|
|
1706
|
+
const definition = RESEARCH_SETUP_CREDENTIALS.find((candidate) => candidate.id === credential.id);
|
|
1707
|
+
if (!definition ||
|
|
1708
|
+
Buffer.byteLength(environment[credential.fromEnvironment] ?? "", "utf8") <
|
|
1709
|
+
definition.minimumUtf8Bytes) {
|
|
1710
|
+
// Credential preflight already proved that an owner-only stored value is
|
|
1711
|
+
// available. Do not require the source environment to remain populated.
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1714
|
+
await setResearchSetupCredentialFromEnvironment({
|
|
1715
|
+
workspace: plan.workspace.path,
|
|
1716
|
+
credentialId: credential.id,
|
|
1717
|
+
environmentName: credential.fromEnvironment,
|
|
1718
|
+
environment,
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
async function assertRequiredCredentialPreflight(plan, environment) {
|
|
1723
|
+
const definitions = selectedCredentialDefinitions(plan);
|
|
1724
|
+
let adapterCredentials = new Map();
|
|
1725
|
+
try {
|
|
1726
|
+
adapterCredentials = await loadAdapterCredentials(plan.workspace.path, definitions);
|
|
1727
|
+
}
|
|
1728
|
+
catch (error) {
|
|
1729
|
+
if (error instanceof CliError)
|
|
1730
|
+
throw error;
|
|
1731
|
+
}
|
|
1732
|
+
let configuredBrokerIds = new Set();
|
|
1733
|
+
try {
|
|
1734
|
+
const declarations = await loadCapabilityDeclarations(plan.workspace.path);
|
|
1735
|
+
const status = await inspectCapabilityCredentialEnvironment(plan.workspace.path, declarations.capabilities);
|
|
1736
|
+
configuredBrokerIds = new Set(status.configuredIds);
|
|
1737
|
+
}
|
|
1738
|
+
catch {
|
|
1739
|
+
// A first apply has not configured capability declarations yet. The
|
|
1740
|
+
// reviewed plan's environment mapping remains the only accepted source.
|
|
1741
|
+
}
|
|
1742
|
+
const failures = [];
|
|
1743
|
+
for (const definition of definitions) {
|
|
1744
|
+
const planned = plan.credentialSources.find((candidate) => candidate.id === definition.id);
|
|
1745
|
+
const stored = definition.storage === "broker"
|
|
1746
|
+
? configuredBrokerIds.has(definition.id)
|
|
1747
|
+
: adapterCredentials.has(definition.id);
|
|
1748
|
+
const supplied = planned !== undefined &&
|
|
1749
|
+
Buffer.byteLength(environment[planned.fromEnvironment] ?? "", "utf8") >=
|
|
1750
|
+
definition.minimumUtf8Bytes;
|
|
1751
|
+
if ((!planned && definition.required && !stored) || (planned && !supplied && !stored)) {
|
|
1752
|
+
failures.push({
|
|
1753
|
+
id: definition.id,
|
|
1754
|
+
environmentName: planned?.fromEnvironment ?? null,
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
if (failures.length) {
|
|
1759
|
+
throw setupError({
|
|
1760
|
+
code: "RESEARCH_SETUP_CREDENTIAL_PREFLIGHT_FAILED",
|
|
1761
|
+
step: "credential-preflight",
|
|
1762
|
+
reason: `Required or explicitly selected credentials are unavailable: ${failures
|
|
1763
|
+
.map((failure) => failure.id)
|
|
1764
|
+
.join(", ")}.`,
|
|
1765
|
+
minimumAction: `Set the reviewed owner environment variables before any download (${failures
|
|
1766
|
+
.map((failure) => `${failure.id}=${failure.environmentName ?? "<mapping-required>"}`)
|
|
1767
|
+
.join(", ")}), then retry this exact step.`,
|
|
1768
|
+
retryCommand: `tiangong-ai research setup retry --step credential-preflight --workspace ${plan.workspace.path} --json`,
|
|
1769
|
+
exitCode: 3,
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
function selectedCredentialDefinitions(plan) {
|
|
1774
|
+
return credentialDefinitionsForSkills(plan.selection.skillIds.map(setupSkill));
|
|
1775
|
+
}
|
|
1776
|
+
function credentialDefinitionsForSkills(selected) {
|
|
1777
|
+
const ids = new Set(selected.flatMap((skill) => skill.credentialIds));
|
|
1778
|
+
return RESEARCH_SETUP_CREDENTIALS.filter((credential) => ids.has(credential.id)).sort((left, right) => left.id.localeCompare(right.id));
|
|
1779
|
+
}
|
|
1780
|
+
function requiredSettingsForSkills(selected) {
|
|
1781
|
+
const ids = new Set(selected.flatMap((skill) => skill.settingIds));
|
|
1782
|
+
return RESEARCH_SETUP_SETTINGS.filter((setting) => ids.has(setting.id)).sort((left, right) => left.id.localeCompare(right.id));
|
|
1783
|
+
}
|
|
1784
|
+
async function verifiedCompanionSkillDirectory(plan, skill) {
|
|
1785
|
+
const candidates = [...plan.install.targets].sort((left, right) => {
|
|
1786
|
+
if (left.agent === "codex" && right.agent !== "codex")
|
|
1787
|
+
return -1;
|
|
1788
|
+
if (right.agent === "codex" && left.agent !== "codex")
|
|
1789
|
+
return 1;
|
|
1790
|
+
return left.agent.localeCompare(right.agent);
|
|
1791
|
+
});
|
|
1792
|
+
for (const target of candidates) {
|
|
1793
|
+
const directory = join(target.root, skill.skillName);
|
|
1794
|
+
const info = await lstat(directory).catch(() => undefined);
|
|
1795
|
+
if (!info?.isDirectory() || info.isSymbolicLink())
|
|
1796
|
+
continue;
|
|
1797
|
+
if ((await hashRegularTree(directory)) === skill.expectedTreeSha256)
|
|
1798
|
+
return directory;
|
|
1799
|
+
}
|
|
1800
|
+
throw setupError({
|
|
1801
|
+
code: "RESEARCH_SETUP_COMPANION_INSTALL_INVALID",
|
|
1802
|
+
step: "companion-preflight",
|
|
1803
|
+
reason: `${skill.id} is missing, symlinked, or does not match the reviewed tree hash.`,
|
|
1804
|
+
minimumAction: "Run setup status and apply the immutable plan; never execute a drifted companion tree.",
|
|
1805
|
+
retryCommand: `tiangong-ai research setup status --workspace ${plan.workspace.path} --json`,
|
|
1806
|
+
exitCode: 3,
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
async function runDocumentGranularCompanion(input) {
|
|
1810
|
+
const sourcePath = requireAbsoluteCompanionPath(input.input.inputPath, "--input");
|
|
1811
|
+
const destination = requireAbsoluteCompanionPath(input.input.outputPath, "--output");
|
|
1812
|
+
if (sourcePath === destination) {
|
|
1813
|
+
throw companionPathError(input.root, "Input and output paths must be different.");
|
|
1814
|
+
}
|
|
1815
|
+
const sourceInfo = await requireRegularCompanionFile(input.root, sourcePath, "input");
|
|
1816
|
+
if (sourceInfo.size <= 0) {
|
|
1817
|
+
throw companionPathError(input.root, "The input file is empty.");
|
|
1818
|
+
}
|
|
1819
|
+
await requireNewCompanionDestination(input.root, destination);
|
|
1820
|
+
const sourceSha256 = await sha256File(sourcePath);
|
|
1821
|
+
const tokenDefinition = input.credentialDefinitions.find((definition) => definition.id === "tiangong.unstructure.auth-token");
|
|
1822
|
+
const token = input.credentials.get("tiangong.unstructure.auth-token");
|
|
1823
|
+
if (!tokenDefinition || !token) {
|
|
1824
|
+
throw setupError({
|
|
1825
|
+
code: "RESEARCH_SETUP_CREDENTIAL_MISSING",
|
|
1826
|
+
step: "companion-preflight",
|
|
1827
|
+
reason: "The selected document adapter has no configured authorization credential.",
|
|
1828
|
+
minimumAction: "Use research setup credential set with an owner environment variable, then retry.",
|
|
1829
|
+
retryCommand: `tiangong-ai research setup credential set --id tiangong.unstructure.auth-token --from-env <OWNER_ENV_NAME> --workspace ${input.root} --json`,
|
|
1830
|
+
exitCode: 3,
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
const endpoint = input.plan.settings["tiangong.unstructure.base-url"];
|
|
1834
|
+
if (!endpoint) {
|
|
1835
|
+
throw setupError({
|
|
1836
|
+
code: "RESEARCH_SETUP_SETTING_MISSING",
|
|
1837
|
+
step: "companion-preflight",
|
|
1838
|
+
reason: "The immutable setup plan has no Tiangong Unstructure base URL.",
|
|
1839
|
+
minimumAction: "Create a replacement setup plan with the required HTTPS base URL.",
|
|
1840
|
+
retryCommand: `tiangong-ai research setup status --workspace ${input.root} --json`,
|
|
1841
|
+
exitCode: 3,
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
const script = join(input.skillDirectory, "scripts", "mineru_fulltext_extract.py");
|
|
1845
|
+
await requireRegularCompanionFile(input.root, script, "adapter script");
|
|
1846
|
+
const temporary = join(dirname(destination), `.${basename(destination)}.${process.pid}.${randomUUID()}.part`);
|
|
1847
|
+
const timeoutSeconds = normalizedCompanionTimeout(input.input.timeoutSeconds, 600, 3_600);
|
|
1848
|
+
const childEnvironment = companionEnvironment(input.environment);
|
|
1849
|
+
childEnvironment.UNSTRUCTURED_AUTH_TOKEN = token;
|
|
1850
|
+
childEnvironment.UNSTRUCTURED_API_BASE_URL = endpoint;
|
|
1851
|
+
const provider = input.plan.settings["tiangong.unstructure.provider"];
|
|
1852
|
+
const model = input.plan.settings["tiangong.unstructure.model"];
|
|
1853
|
+
if (provider)
|
|
1854
|
+
childEnvironment.UNSTRUCTURED_PROVIDER = provider;
|
|
1855
|
+
if (model)
|
|
1856
|
+
childEnvironment.UNSTRUCTURED_MODEL = model;
|
|
1857
|
+
let destinationLinked = false;
|
|
1858
|
+
let committed = false;
|
|
1859
|
+
try {
|
|
1860
|
+
await runChecked(input.runner, "python3", [script, "--file", sourcePath, "--output", temporary, "--timeout", String(timeoutSeconds)], input.root, childEnvironment, "companion-document-extract", (timeoutSeconds + 30) * 1_000);
|
|
1861
|
+
if ((await sha256File(sourcePath)) !== sourceSha256 ||
|
|
1862
|
+
(await fileSize(sourcePath)) !== sourceInfo.size) {
|
|
1863
|
+
throw setupError({
|
|
1864
|
+
code: "RESEARCH_SETUP_COMPANION_INPUT_CHANGED",
|
|
1865
|
+
step: "companion-document-extract",
|
|
1866
|
+
reason: "The source document changed while preprocessing was running.",
|
|
1867
|
+
minimumAction: "Retry with a stable, immutable input file.",
|
|
1868
|
+
retryCommand: `tiangong-ai research setup companion run --help`,
|
|
1869
|
+
exitCode: 3,
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
const outputInfo = await requireRegularCompanionFile(input.root, temporary, "temporary output");
|
|
1873
|
+
if (outputInfo.size <= 0 || outputInfo.size > 128 * 1024 * 1024) {
|
|
1874
|
+
throw setupError({
|
|
1875
|
+
code: "RESEARCH_SETUP_COMPANION_OUTPUT_INVALID",
|
|
1876
|
+
step: "companion-document-extract",
|
|
1877
|
+
reason: "The extracted full text is empty or exceeds the 128 MiB adapter limit.",
|
|
1878
|
+
minimumAction: "Inspect the source and service response, then retry with a bounded document.",
|
|
1879
|
+
retryCommand: `tiangong-ai research setup companion run --help`,
|
|
1880
|
+
exitCode: 3,
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
await requireNewCompanionDestination(input.root, destination);
|
|
1884
|
+
await link(temporary, destination).catch((error) => {
|
|
1885
|
+
throw setupError({
|
|
1886
|
+
code: "RESEARCH_SETUP_COMPANION_COMMIT_FAILED",
|
|
1887
|
+
step: "companion-document-commit",
|
|
1888
|
+
reason: `The no-overwrite atomic output commit failed (${sanitizeResearchText(error instanceof Error ? error.message : String(error))}).`,
|
|
1889
|
+
minimumAction: "Choose a new explicit output path on the same filesystem and retry; existing files are never replaced.",
|
|
1890
|
+
retryCommand: `tiangong-ai research setup companion run --help`,
|
|
1891
|
+
exitCode: 3,
|
|
1892
|
+
});
|
|
1893
|
+
});
|
|
1894
|
+
destinationLinked = true;
|
|
1895
|
+
await chmod(destination, 0o600).catch(async (error) => {
|
|
1896
|
+
await rm(destination, { force: true });
|
|
1897
|
+
destinationLinked = false;
|
|
1898
|
+
throw error;
|
|
1899
|
+
});
|
|
1900
|
+
const outputSha256 = await sha256File(destination);
|
|
1901
|
+
await appendJournalEvent(workspacePaths(input.root).journal, "research.setup.companion.document.completed", "workspace", {
|
|
1902
|
+
planSha256: input.plan.planSha256,
|
|
1903
|
+
skillId: input.skill.id,
|
|
1904
|
+
skillTreeSha256: input.skill.expectedTreeSha256,
|
|
1905
|
+
sourceRef: setupSource(input.skill.sourceId).immutableRef,
|
|
1906
|
+
input: { sha256: sourceSha256, bytes: sourceInfo.size },
|
|
1907
|
+
output: { sha256: outputSha256, bytes: outputInfo.size },
|
|
1908
|
+
});
|
|
1909
|
+
committed = true;
|
|
1910
|
+
return {
|
|
1911
|
+
schemaVersion: 1,
|
|
1912
|
+
kind: "research-setup-companion-result",
|
|
1913
|
+
status: "complete",
|
|
1914
|
+
workspace: input.root,
|
|
1915
|
+
skillId: input.skill.id,
|
|
1916
|
+
role: input.skill.role,
|
|
1917
|
+
input: { path: sourcePath, sha256: sourceSha256, bytes: sourceInfo.size },
|
|
1918
|
+
output: { path: destination, sha256: outputSha256, bytes: outputInfo.size },
|
|
1919
|
+
provenance: companionProvenance(input.plan, input.skill),
|
|
1920
|
+
next: "Admit the exact output path as a declared research input; preprocessing does not itself admit evidence.",
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
finally {
|
|
1924
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
1925
|
+
if (destinationLinked && !committed) {
|
|
1926
|
+
await rm(destination, { force: true }).catch(() => undefined);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
async function runAcademicPaperCompanion(input) {
|
|
1931
|
+
const outputDirectory = requireAbsoluteCompanionPath(input.input.outputDirectory, "--out");
|
|
1932
|
+
const outputInfo = await lstat(outputDirectory).catch(() => undefined);
|
|
1933
|
+
if (!outputInfo?.isDirectory() || outputInfo.isSymbolicLink()) {
|
|
1934
|
+
throw companionPathError(input.root, "--out must be an existing regular non-symlink directory.");
|
|
1935
|
+
}
|
|
1936
|
+
const doi = input.input.doi?.trim();
|
|
1937
|
+
const title = input.input.title?.trim();
|
|
1938
|
+
if (Boolean(doi) === Boolean(title)) {
|
|
1939
|
+
throw setupError({
|
|
1940
|
+
code: "RESEARCH_SETUP_COMPANION_ARGUMENT_INVALID",
|
|
1941
|
+
step: "companion-preflight",
|
|
1942
|
+
reason: "Academic paper acquisition requires exactly one of --doi or --title.",
|
|
1943
|
+
minimumAction: "Provide one exact paper identifier and retry.",
|
|
1944
|
+
retryCommand: `tiangong-ai research setup companion run --help`,
|
|
1945
|
+
exitCode: 2,
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
if ((input.input.author || input.input.year !== undefined) && !title) {
|
|
1949
|
+
throw setupError({
|
|
1950
|
+
code: "RESEARCH_SETUP_COMPANION_ARGUMENT_INVALID",
|
|
1951
|
+
step: "companion-preflight",
|
|
1952
|
+
reason: "--author and --year may be used only together with --title.",
|
|
1953
|
+
minimumAction: "Provide --title, or remove the disambiguation options.",
|
|
1954
|
+
retryCommand: `tiangong-ai research setup companion run --help`,
|
|
1955
|
+
exitCode: 2,
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
if (input.input.year !== undefined &&
|
|
1959
|
+
(!Number.isInteger(input.input.year) || input.input.year < 1000 || input.input.year > 9999)) {
|
|
1960
|
+
throw setupError({
|
|
1961
|
+
code: "RESEARCH_SETUP_COMPANION_ARGUMENT_INVALID",
|
|
1962
|
+
step: "companion-preflight",
|
|
1963
|
+
reason: "--year must be a four-digit integer.",
|
|
1964
|
+
minimumAction: "Correct the publication year and retry.",
|
|
1965
|
+
retryCommand: `tiangong-ai research setup companion run --help`,
|
|
1966
|
+
exitCode: 2,
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
const script = join(input.skillDirectory, "scripts", "fetch.py");
|
|
1970
|
+
await requireRegularCompanionFile(input.root, script, "adapter script");
|
|
1971
|
+
const timeoutSeconds = normalizedCompanionTimeout(input.input.timeoutSeconds, 30, 600);
|
|
1972
|
+
const args = [script];
|
|
1973
|
+
if (doi)
|
|
1974
|
+
args.push(doi);
|
|
1975
|
+
else
|
|
1976
|
+
args.push("--title", title);
|
|
1977
|
+
if (input.input.author)
|
|
1978
|
+
args.push("--author", input.input.author);
|
|
1979
|
+
if (input.input.year !== undefined)
|
|
1980
|
+
args.push("--year", String(input.input.year));
|
|
1981
|
+
args.push("--out", outputDirectory, "--format", "json", "--timeout", String(timeoutSeconds));
|
|
1982
|
+
const childEnvironment = companionEnvironment(input.environment);
|
|
1983
|
+
const semanticScholarKey = input.credentials.get("semantic-scholar.api-key");
|
|
1984
|
+
if (semanticScholarKey)
|
|
1985
|
+
childEnvironment.SEMANTIC_SCHOLAR_API_KEY = semanticScholarKey;
|
|
1986
|
+
const unpaywallEmail = input.plan.settings["unpaywall.contact-email"];
|
|
1987
|
+
if (unpaywallEmail)
|
|
1988
|
+
childEnvironment.UNPAYWALL_EMAIL = unpaywallEmail;
|
|
1989
|
+
const execution = await input.runner({
|
|
1990
|
+
command: "python3",
|
|
1991
|
+
args,
|
|
1992
|
+
cwd: input.root,
|
|
1993
|
+
environment: childEnvironment,
|
|
1994
|
+
timeoutMs: (timeoutSeconds + 60) * 4 * 1_000,
|
|
1995
|
+
});
|
|
1996
|
+
const envelope = parseCompanionJson(execution.stdout, input.root, "academic-paper-download");
|
|
1997
|
+
const results = isObject(envelope.data) && Array.isArray(envelope.data.results) ? envelope.data.results : [];
|
|
1998
|
+
if (results.length !== 1 || !isObject(results[0])) {
|
|
1999
|
+
throw setupError({
|
|
2000
|
+
code: "RESEARCH_SETUP_COMPANION_OUTPUT_INVALID",
|
|
2001
|
+
step: "companion-paper-download",
|
|
2002
|
+
reason: "The pinned paper adapter did not return exactly one structured result.",
|
|
2003
|
+
minimumAction: "Verify the pinned Python dependencies and rerun setup doctor.",
|
|
2004
|
+
retryCommand: `tiangong-ai research setup doctor --workspace ${input.root} --json`,
|
|
2005
|
+
exitCode: 3,
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
const result = results[0];
|
|
2009
|
+
if (execution.exitCode !== 0 || result.success !== true) {
|
|
2010
|
+
if (result.success === false &&
|
|
2011
|
+
isObject(result.browser_handoff) &&
|
|
2012
|
+
result.file === null &&
|
|
2013
|
+
result.manifest === null) {
|
|
2014
|
+
await appendJournalEvent(workspacePaths(input.root).journal, "research.setup.companion.paper.handoff-required", "workspace", {
|
|
2015
|
+
planSha256: input.plan.planSha256,
|
|
2016
|
+
skillId: input.skill.id,
|
|
2017
|
+
skillTreeSha256: input.skill.expectedTreeSha256,
|
|
2018
|
+
querySha256: sha256Text(doi ?? title),
|
|
2019
|
+
sourcesTried: Array.isArray(result.sources_tried) ? result.sources_tried : [],
|
|
2020
|
+
artifactCommitted: false,
|
|
2021
|
+
});
|
|
2022
|
+
return {
|
|
2023
|
+
schemaVersion: 1,
|
|
2024
|
+
kind: "research-setup-companion-result",
|
|
2025
|
+
status: "browser-handoff-required",
|
|
2026
|
+
workspace: input.root,
|
|
2027
|
+
skillId: input.skill.id,
|
|
2028
|
+
role: input.skill.role,
|
|
2029
|
+
artifactCommitted: false,
|
|
2030
|
+
sourcesTried: Array.isArray(result.sources_tried) ? result.sources_tried : [],
|
|
2031
|
+
error: sanitizeResearchRecord(isObject(result.error) ? result.error : {}),
|
|
2032
|
+
provenance: companionProvenance(input.plan, input.skill),
|
|
2033
|
+
next: "Automatic legal OA sources were exhausted. Follow the installed academic-paper-download browser-handoff reference explicitly; no browser is launched or selected automatically.",
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
throw setupError({
|
|
2037
|
+
code: "RESEARCH_SETUP_COMPANION_COMMAND_FAILED",
|
|
2038
|
+
step: "companion-paper-download",
|
|
2039
|
+
reason: `The pinned paper adapter exited with status ${execution.exitCode}.`,
|
|
2040
|
+
minimumAction: sanitizeResearchText(execution.stderr).trim().slice(0, 500) ||
|
|
2041
|
+
"Inspect the structured adapter error and verify its pinned Python dependencies.",
|
|
2042
|
+
retryCommand: `tiangong-ai research setup doctor --workspace ${input.root} --json`,
|
|
2043
|
+
exitCode: 3,
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
const artifactPath = requireContainedArtifactPath(result.file, outputDirectory, "file");
|
|
2047
|
+
const manifestPath = requireContainedArtifactPath(result.manifest, outputDirectory, "manifest");
|
|
2048
|
+
const artifactInfo = await requireRegularCompanionFile(input.root, artifactPath, "PDF artifact");
|
|
2049
|
+
const manifestInfo = await requireRegularCompanionFile(input.root, manifestPath, "PDF manifest");
|
|
2050
|
+
if (artifactInfo.size <= 0 || artifactInfo.size > 100 * 1024 * 1024) {
|
|
2051
|
+
throw companionArtifactError(input.root, "The committed PDF size is outside the adapter limit.");
|
|
2052
|
+
}
|
|
2053
|
+
const artifactSha256 = await sha256File(artifactPath);
|
|
2054
|
+
if (result.sha256 !== artifactSha256 || result.size !== artifactInfo.size) {
|
|
2055
|
+
throw companionArtifactError(input.root, "The result metadata does not bind the committed PDF bytes.");
|
|
2056
|
+
}
|
|
2057
|
+
const manifest = await readJsonFile(manifestPath, "Paper manifest");
|
|
2058
|
+
if (manifest.schema_version !== "academic-paper-download.artifact.v2" ||
|
|
2059
|
+
manifest.file !== artifactPath ||
|
|
2060
|
+
manifest.sha256 !== artifactSha256 ||
|
|
2061
|
+
manifest.size !== artifactInfo.size) {
|
|
2062
|
+
throw companionArtifactError(input.root, "The manifest does not bind the exact committed PDF.");
|
|
2063
|
+
}
|
|
2064
|
+
await verifyPdfEnvelope(artifactPath, artifactInfo.size, input.root);
|
|
2065
|
+
const manifestSha256 = await sha256File(manifestPath);
|
|
2066
|
+
await appendJournalEvent(workspacePaths(input.root).journal, "research.setup.companion.paper.completed", "workspace", {
|
|
2067
|
+
planSha256: input.plan.planSha256,
|
|
2068
|
+
skillId: input.skill.id,
|
|
2069
|
+
skillTreeSha256: input.skill.expectedTreeSha256,
|
|
2070
|
+
sourceRef: setupSource(input.skill.sourceId).immutableRef,
|
|
2071
|
+
querySha256: sha256Text(doi ?? title),
|
|
2072
|
+
source: typeof manifest.source === "string" ? manifest.source : null,
|
|
2073
|
+
artifact: { sha256: artifactSha256, bytes: artifactInfo.size },
|
|
2074
|
+
manifest: { sha256: manifestSha256, bytes: manifestInfo.size },
|
|
2075
|
+
});
|
|
2076
|
+
return {
|
|
2077
|
+
schemaVersion: 1,
|
|
2078
|
+
kind: "research-setup-companion-result",
|
|
2079
|
+
status: "complete",
|
|
2080
|
+
workspace: input.root,
|
|
2081
|
+
skillId: input.skill.id,
|
|
2082
|
+
role: input.skill.role,
|
|
2083
|
+
artifact: { path: artifactPath, sha256: artifactSha256, bytes: artifactInfo.size },
|
|
2084
|
+
manifest: { path: manifestPath, sha256: manifestSha256, bytes: manifestInfo.size },
|
|
2085
|
+
source: typeof manifest.source === "string" ? manifest.source : null,
|
|
2086
|
+
provenance: companionProvenance(input.plan, input.skill),
|
|
2087
|
+
validation: [
|
|
2088
|
+
"pinned-adapter-pypdf",
|
|
2089
|
+
"pdf-header",
|
|
2090
|
+
"pdf-eof",
|
|
2091
|
+
"size",
|
|
2092
|
+
"sha256",
|
|
2093
|
+
"atomic-artifact-and-manifest",
|
|
2094
|
+
],
|
|
2095
|
+
next: "Admit the exact PDF or a derived hash-bound view as a declared research input.",
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
function companionEnvironment(source) {
|
|
2099
|
+
const result = installerEnvironment(source);
|
|
2100
|
+
delete result.CI;
|
|
2101
|
+
return result;
|
|
2102
|
+
}
|
|
2103
|
+
function normalizedCompanionTimeout(value, defaultValue, maximum) {
|
|
2104
|
+
const resolved = value ?? defaultValue;
|
|
2105
|
+
if (!Number.isInteger(resolved) || resolved < 1 || resolved > maximum) {
|
|
2106
|
+
throw new CliError(`Companion timeout must be an integer from 1 to ${maximum} seconds.`, {
|
|
2107
|
+
code: "RESEARCH_SETUP_COMPANION_ARGUMENT_INVALID",
|
|
2108
|
+
exitCode: 2,
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
return resolved;
|
|
2112
|
+
}
|
|
2113
|
+
function requireAbsoluteCompanionPath(value, label) {
|
|
2114
|
+
if (!value || !isAbsolute(value) || resolve(value) !== value) {
|
|
2115
|
+
throw new CliError(`${label} must be an absolute canonical path.`, {
|
|
2116
|
+
code: "RESEARCH_SETUP_COMPANION_PATH_INVALID",
|
|
2117
|
+
exitCode: 2,
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
return value;
|
|
2121
|
+
}
|
|
2122
|
+
async function requireRegularCompanionFile(root, path, label) {
|
|
2123
|
+
const info = await lstat(path).catch(() => undefined);
|
|
2124
|
+
if (!info?.isFile() || info.isSymbolicLink()) {
|
|
2125
|
+
throw companionPathError(root, `The ${label} must be a regular non-symlink file.`);
|
|
2126
|
+
}
|
|
2127
|
+
return info;
|
|
2128
|
+
}
|
|
2129
|
+
async function requireNewCompanionDestination(root, destination) {
|
|
2130
|
+
const parentInfo = await lstat(dirname(destination)).catch(() => undefined);
|
|
2131
|
+
if (!parentInfo?.isDirectory() || parentInfo.isSymbolicLink()) {
|
|
2132
|
+
throw companionPathError(root, "The output parent must be an existing regular directory.");
|
|
2133
|
+
}
|
|
2134
|
+
if (await pathExists(destination)) {
|
|
2135
|
+
throw companionPathError(root, "The explicit output path already exists and will not be replaced.");
|
|
2136
|
+
}
|
|
2137
|
+
const dangling = await lstat(destination).catch(() => undefined);
|
|
2138
|
+
if (dangling) {
|
|
2139
|
+
throw companionPathError(root, "The explicit output path is occupied, including by a symlink.");
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
function requireContainedArtifactPath(value, root, label) {
|
|
2143
|
+
if (typeof value !== "string" || !isAbsolute(value) || resolve(value) !== value) {
|
|
2144
|
+
throw companionArtifactError(root, `The adapter returned an invalid ${label} path.`);
|
|
2145
|
+
}
|
|
2146
|
+
const rel = relative(root, value);
|
|
2147
|
+
if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
|
|
2148
|
+
throw companionArtifactError(root, `The adapter returned a ${label} outside --out.`);
|
|
2149
|
+
}
|
|
2150
|
+
return value;
|
|
2151
|
+
}
|
|
2152
|
+
function parseCompanionJson(stdout, root, label) {
|
|
2153
|
+
try {
|
|
2154
|
+
const parsed = JSON.parse(stdout);
|
|
2155
|
+
if (isObject(parsed))
|
|
2156
|
+
return parsed;
|
|
2157
|
+
}
|
|
2158
|
+
catch {
|
|
2159
|
+
// Fall through to the structured error below without echoing untrusted output.
|
|
2160
|
+
}
|
|
2161
|
+
throw setupError({
|
|
2162
|
+
code: "RESEARCH_SETUP_COMPANION_OUTPUT_INVALID",
|
|
2163
|
+
step: "companion-output",
|
|
2164
|
+
reason: `${label} did not emit one valid JSON object.`,
|
|
2165
|
+
minimumAction: "Verify the pinned Skill tree and its locked dependencies, then retry.",
|
|
2166
|
+
retryCommand: `tiangong-ai research setup doctor --workspace ${root} --json`,
|
|
2167
|
+
exitCode: 3,
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
async function verifyPdfEnvelope(path, size, root) {
|
|
2171
|
+
const handle = await open(path, "r");
|
|
2172
|
+
try {
|
|
2173
|
+
const header = Buffer.alloc(5);
|
|
2174
|
+
await handle.read(header, 0, 5, 0);
|
|
2175
|
+
const tailLength = Math.min(8_192, size);
|
|
2176
|
+
const tail = Buffer.alloc(tailLength);
|
|
2177
|
+
await handle.read(tail, 0, tailLength, size - tailLength);
|
|
2178
|
+
if (header.toString("ascii") !== "%PDF-" || !tail.includes(Buffer.from("%%EOF"))) {
|
|
2179
|
+
throw companionArtifactError(root, "The committed artifact failed PDF envelope validation.");
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
finally {
|
|
2183
|
+
await handle.close();
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
function companionProvenance(plan, skill) {
|
|
2187
|
+
return {
|
|
2188
|
+
planSha256: plan.planSha256,
|
|
2189
|
+
sourceId: skill.sourceId,
|
|
2190
|
+
sourceRef: setupSource(skill.sourceId).immutableRef,
|
|
2191
|
+
skillTreeSha256: skill.expectedTreeSha256,
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
function companionPathError(root, reason) {
|
|
2195
|
+
return setupError({
|
|
2196
|
+
code: "RESEARCH_SETUP_COMPANION_PATH_INVALID",
|
|
2197
|
+
step: "companion-preflight",
|
|
2198
|
+
reason,
|
|
2199
|
+
minimumAction: "Use explicit regular paths with no symlink at the input, output, or parent.",
|
|
2200
|
+
retryCommand: `tiangong-ai research setup companion run --help`,
|
|
2201
|
+
exitCode: 2,
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
function companionArtifactError(root, reason) {
|
|
2205
|
+
return setupError({
|
|
2206
|
+
code: "RESEARCH_SETUP_COMPANION_ARTIFACT_INVALID",
|
|
2207
|
+
step: "companion-paper-verify",
|
|
2208
|
+
reason,
|
|
2209
|
+
minimumAction: "Do not admit the file. Inspect the pinned adapter environment and rerun acquisition to a clean directory.",
|
|
2210
|
+
retryCommand: `tiangong-ai research setup doctor --workspace ${root} --json`,
|
|
2211
|
+
exitCode: 3,
|
|
2212
|
+
});
|
|
2213
|
+
}
|
|
2214
|
+
async function loadAdapterCredentials(root, definitions) {
|
|
2215
|
+
const path = workspacePaths(root).setupAdapterEnv;
|
|
2216
|
+
if (!(await pathExists(path)))
|
|
2217
|
+
return new Map();
|
|
2218
|
+
const info = await lstat(path).catch(() => undefined);
|
|
2219
|
+
if (!info?.isFile() || info.isSymbolicLink()) {
|
|
2220
|
+
throw setupError({
|
|
2221
|
+
code: "RESEARCH_SETUP_CREDENTIAL_STORE_INVALID",
|
|
2222
|
+
step: "credentials",
|
|
2223
|
+
reason: "Adapter credential store must be a regular non-symlink file.",
|
|
2224
|
+
minimumAction: "Repair the owner-only adapter credential store; do not follow or replace a symlink.",
|
|
2225
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
2226
|
+
exitCode: 3,
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
if (process.platform !== "win32" && (info.mode & 0o077) !== 0) {
|
|
2230
|
+
throw setupError({
|
|
2231
|
+
code: "RESEARCH_SETUP_CREDENTIAL_STORE_INVALID",
|
|
2232
|
+
step: "credentials",
|
|
2233
|
+
reason: "Adapter credential store must have owner-only permissions.",
|
|
2234
|
+
minimumAction: `Run chmod 600 on ${path}, then retry doctor.`,
|
|
2235
|
+
retryCommand: `tiangong-ai research setup doctor --workspace ${root} --json`,
|
|
2236
|
+
exitCode: 3,
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
if (info.size > 64 * 1024) {
|
|
2240
|
+
throw setupError({
|
|
2241
|
+
code: "RESEARCH_SETUP_CREDENTIAL_STORE_INVALID",
|
|
2242
|
+
step: "credentials",
|
|
2243
|
+
reason: "Adapter credential store exceeds the supported size.",
|
|
2244
|
+
minimumAction: "Remove unrelated material from the credential store.",
|
|
2245
|
+
retryCommand: `tiangong-ai research setup doctor --workspace ${root} --json`,
|
|
2246
|
+
exitCode: 3,
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
const content = await readFile(path, "utf8");
|
|
2250
|
+
const lines = content
|
|
2251
|
+
.split(/\r?\n/)
|
|
2252
|
+
.map((line) => line.trim())
|
|
2253
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
2254
|
+
if (lines.length !== 1 || !lines[0].startsWith(`${ADAPTER_ENV_KEY}=`)) {
|
|
2255
|
+
throw setupError({
|
|
2256
|
+
code: "RESEARCH_SETUP_CREDENTIAL_STORE_INVALID",
|
|
2257
|
+
step: "credentials",
|
|
2258
|
+
reason: "Adapter credential store has unsupported keys or duplicate configuration.",
|
|
2259
|
+
minimumAction: "Use research setup credential set to create the supported owner-only format.",
|
|
2260
|
+
retryCommand: `tiangong-ai research setup credential set --help`,
|
|
2261
|
+
exitCode: 3,
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
2264
|
+
let value;
|
|
2265
|
+
try {
|
|
2266
|
+
value = JSON.parse(lines[0].slice(ADAPTER_ENV_KEY.length + 1));
|
|
2267
|
+
}
|
|
2268
|
+
catch {
|
|
2269
|
+
value = null;
|
|
2270
|
+
}
|
|
2271
|
+
if (!isObject(value)) {
|
|
2272
|
+
throw setupError({
|
|
2273
|
+
code: "RESEARCH_SETUP_CREDENTIAL_STORE_INVALID",
|
|
2274
|
+
step: "credentials",
|
|
2275
|
+
reason: "Adapter credential JSON is invalid.",
|
|
2276
|
+
minimumAction: "Use research setup credential set to rewrite the owner-only store.",
|
|
2277
|
+
retryCommand: `tiangong-ai research setup credential set --help`,
|
|
2278
|
+
exitCode: 3,
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
const allowed = new Map(definitions.map((definition) => [definition.id, definition]));
|
|
2282
|
+
const result = new Map();
|
|
2283
|
+
for (const [id, credentialValue] of Object.entries(value)) {
|
|
2284
|
+
const definition = allowed.get(id);
|
|
2285
|
+
if (!definition ||
|
|
2286
|
+
typeof credentialValue !== "string" ||
|
|
2287
|
+
Buffer.byteLength(credentialValue, "utf8") < definition.minimumUtf8Bytes) {
|
|
2288
|
+
throw setupError({
|
|
2289
|
+
code: "RESEARCH_SETUP_CREDENTIAL_STORE_INVALID",
|
|
2290
|
+
step: "credentials",
|
|
2291
|
+
reason: `Adapter credential entry is undeclared or invalid: ${id}.`,
|
|
2292
|
+
minimumAction: "Create a new setup plan or repair credentials through the supported command.",
|
|
2293
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
2294
|
+
exitCode: 3,
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
result.set(id, credentialValue);
|
|
2298
|
+
}
|
|
2299
|
+
return result;
|
|
2300
|
+
}
|
|
2301
|
+
async function setAdapterCredential(root, definitions, credentialId, value) {
|
|
2302
|
+
const configured = await loadAdapterCredentials(root, definitions);
|
|
2303
|
+
configured.set(credentialId, value);
|
|
2304
|
+
const serialized = Object.fromEntries([...configured.entries()].sort(([left], [right]) => left.localeCompare(right)));
|
|
2305
|
+
await writeTextAtomic(workspacePaths(root).setupAdapterEnv, `${ADAPTER_ENV_KEY}=${JSON.stringify(serialized)}\n`, 0o600);
|
|
2306
|
+
}
|
|
2307
|
+
function initialSetupState(planSha256) {
|
|
2308
|
+
return {
|
|
2309
|
+
schemaVersion: 1,
|
|
2310
|
+
planSha256,
|
|
2311
|
+
status: "pending",
|
|
2312
|
+
currentStep: null,
|
|
2313
|
+
completedSteps: [],
|
|
2314
|
+
attempts: 0,
|
|
2315
|
+
updatedAt: new Date().toISOString(),
|
|
2316
|
+
lastError: null,
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
async function loadSetupState(root, planSha256) {
|
|
2320
|
+
const path = workspacePaths(root).setupState;
|
|
2321
|
+
if (!(await pathExists(path)))
|
|
2322
|
+
return initialSetupState(planSha256);
|
|
2323
|
+
const value = await readJsonFile(path, "Research setup state");
|
|
2324
|
+
if (!isObject(value) ||
|
|
2325
|
+
value.schemaVersion !== 1 ||
|
|
2326
|
+
value.planSha256 !== planSha256 ||
|
|
2327
|
+
!["pending", "applying", "partially-ready", "ready", "blocked"].includes(String(value.status)) ||
|
|
2328
|
+
!(value.currentStep === null || typeof value.currentStep === "string") ||
|
|
2329
|
+
!Array.isArray(value.completedSteps) ||
|
|
2330
|
+
value.completedSteps.some((step) => typeof step !== "string") ||
|
|
2331
|
+
typeof value.attempts !== "number" ||
|
|
2332
|
+
!Number.isInteger(value.attempts) ||
|
|
2333
|
+
typeof value.updatedAt !== "string" ||
|
|
2334
|
+
!(value.lastError === null || isObject(value.lastError))) {
|
|
2335
|
+
throw setupError({
|
|
2336
|
+
code: "RESEARCH_SETUP_STATE_INVALID",
|
|
2337
|
+
step: "state",
|
|
2338
|
+
reason: "Setup state is malformed or belongs to a different plan.",
|
|
2339
|
+
minimumAction: "Inspect the immutable plan and state; do not continue from unbound state.",
|
|
2340
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
2341
|
+
exitCode: 3,
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
return value;
|
|
2345
|
+
}
|
|
2346
|
+
async function updateSetupState(root, state) {
|
|
2347
|
+
const next = sanitizeResearchRecord({
|
|
2348
|
+
...state,
|
|
2349
|
+
updatedAt: new Date().toISOString(),
|
|
2350
|
+
});
|
|
2351
|
+
await writeJsonAtomic(workspacePaths(root).setupState, next);
|
|
2352
|
+
return next;
|
|
2353
|
+
}
|
|
2354
|
+
async function startSetupStep(root, state, step) {
|
|
2355
|
+
return updateSetupState(root, { ...state, status: "applying", currentStep: step });
|
|
2356
|
+
}
|
|
2357
|
+
async function completeSetupStep(root, state, step) {
|
|
2358
|
+
return updateSetupState(root, {
|
|
2359
|
+
...state,
|
|
2360
|
+
status: "applying",
|
|
2361
|
+
currentStep: null,
|
|
2362
|
+
completedSteps: [...new Set([...state.completedSteps, step])],
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
async function archiveSetupGeneration(root) {
|
|
2366
|
+
const paths = workspacePaths(root);
|
|
2367
|
+
const prior = await loadHashVerifiedResearchSetupPlan(paths.setupPlan);
|
|
2368
|
+
const archiveRoot = join(paths.control, "setup-history", prior.planSha256);
|
|
2369
|
+
await assertNoSymlinkedExistingPath(dirname(archiveRoot), root);
|
|
2370
|
+
await ensureDirectory(archiveRoot);
|
|
2371
|
+
const files = [
|
|
2372
|
+
[paths.setupPlan, "setup-plan.json"],
|
|
2373
|
+
[paths.setupState, "setup-state.json"],
|
|
2374
|
+
[paths.setupReport, "setup-report.json"],
|
|
2375
|
+
];
|
|
2376
|
+
for (const [source, name] of files) {
|
|
2377
|
+
if (!(await pathExists(source)))
|
|
2378
|
+
continue;
|
|
2379
|
+
const sourceInfo = await lstat(source);
|
|
2380
|
+
if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink()) {
|
|
2381
|
+
throw setupError({
|
|
2382
|
+
code: "RESEARCH_SETUP_ARCHIVE_INVALID",
|
|
2383
|
+
step: "upgrade-plan",
|
|
2384
|
+
reason: `Setup generation file is not a regular non-symlink file: ${name}.`,
|
|
2385
|
+
minimumAction: "Inspect the current generation before creating an upgrade plan.",
|
|
2386
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
2387
|
+
exitCode: 3,
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
const destination = join(archiveRoot, name);
|
|
2391
|
+
const content = await readFile(source, "utf8");
|
|
2392
|
+
if (await pathExists(destination)) {
|
|
2393
|
+
const destinationInfo = await lstat(destination);
|
|
2394
|
+
if (!destinationInfo.isFile() ||
|
|
2395
|
+
destinationInfo.isSymbolicLink() ||
|
|
2396
|
+
(await readFile(destination, "utf8")) !== content) {
|
|
2397
|
+
throw setupError({
|
|
2398
|
+
code: "RESEARCH_SETUP_ARCHIVE_INVALID",
|
|
2399
|
+
step: "upgrade-plan",
|
|
2400
|
+
reason: `Existing setup history does not match the generation being archived: ${name}.`,
|
|
2401
|
+
minimumAction: "Stop and audit setup-history; the CLI will not overwrite it.",
|
|
2402
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
2403
|
+
exitCode: 3,
|
|
2404
|
+
});
|
|
2405
|
+
}
|
|
2406
|
+
continue;
|
|
2407
|
+
}
|
|
2408
|
+
await writeTextAtomic(destination, content, 0o444);
|
|
2409
|
+
}
|
|
2410
|
+
return prior.planSha256;
|
|
2411
|
+
}
|
|
2412
|
+
function requireAbsoluteWorkspace(value) {
|
|
2413
|
+
if (!value || !isAbsolute(value) || /[\0\r\n]/.test(value)) {
|
|
2414
|
+
throw setupError({
|
|
2415
|
+
code: "RESEARCH_SETUP_WORKSPACE_INVALID",
|
|
2416
|
+
step: "workspace",
|
|
2417
|
+
reason: "Setup workspace must be an explicit absolute path.",
|
|
2418
|
+
minimumAction: "Choose or create an absolute workspace directory, then retry.",
|
|
2419
|
+
retryCommand: "tiangong-ai research setup --help",
|
|
2420
|
+
exitCode: 2,
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
return resolve(value);
|
|
2424
|
+
}
|
|
2425
|
+
async function assertWorkspaceDirectory(root) {
|
|
2426
|
+
const info = await lstat(root).catch(() => undefined);
|
|
2427
|
+
if (!info?.isDirectory() || info.isSymbolicLink()) {
|
|
2428
|
+
throw setupError({
|
|
2429
|
+
code: "RESEARCH_SETUP_WORKSPACE_INVALID",
|
|
2430
|
+
step: "workspace",
|
|
2431
|
+
reason: "Setup workspace must exist as a regular non-symlink directory.",
|
|
2432
|
+
minimumAction: `Create the directory explicitly, then retry with --workspace ${root}.`,
|
|
2433
|
+
retryCommand: "tiangong-ai research setup --help",
|
|
2434
|
+
exitCode: 2,
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
await assertNoSymlinkedExistingPath(root);
|
|
2438
|
+
}
|
|
2439
|
+
function normalizedWorkspaceName(value) {
|
|
2440
|
+
const normalized = value.trim();
|
|
2441
|
+
if (!normalized || normalized.length > 100 || /[\u0000-\u001f]/.test(normalized)) {
|
|
2442
|
+
throw setupError({
|
|
2443
|
+
code: "RESEARCH_SETUP_WORKSPACE_NAME_INVALID",
|
|
2444
|
+
step: "workspace",
|
|
2445
|
+
reason: "Workspace name must contain 1-100 printable characters.",
|
|
2446
|
+
minimumAction: "Choose a short printable workspace name.",
|
|
2447
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
2448
|
+
exitCode: 2,
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
return normalized;
|
|
2452
|
+
}
|
|
2453
|
+
function assertEnvironmentName(value) {
|
|
2454
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(value)) {
|
|
2455
|
+
throw setupError({
|
|
2456
|
+
code: "RESEARCH_SETUP_CREDENTIAL_INVALID",
|
|
2457
|
+
step: "credentials",
|
|
2458
|
+
reason: "Credential source must be the name of one environment variable.",
|
|
2459
|
+
minimumAction: "Use a variable name such as BRAVE_API_KEY; never put the credential value in CLI arguments.",
|
|
2460
|
+
retryCommand: "tiangong-ai research setup credential set --help",
|
|
2461
|
+
exitCode: 2,
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
function validateSetupSetting(id, validation, value) {
|
|
2466
|
+
let valid = false;
|
|
2467
|
+
if (validation === "https-url") {
|
|
2468
|
+
try {
|
|
2469
|
+
const url = new URL(value);
|
|
2470
|
+
valid =
|
|
2471
|
+
url.protocol === "https:" &&
|
|
2472
|
+
!url.username &&
|
|
2473
|
+
!url.password &&
|
|
2474
|
+
!url.hash &&
|
|
2475
|
+
!url.search &&
|
|
2476
|
+
url.hostname.length > 0;
|
|
2477
|
+
}
|
|
2478
|
+
catch {
|
|
2479
|
+
valid = false;
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
else if (validation === "email") {
|
|
2483
|
+
valid =
|
|
2484
|
+
value.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) && !/[\r\n\0]/.test(value);
|
|
2485
|
+
}
|
|
2486
|
+
else {
|
|
2487
|
+
valid =
|
|
2488
|
+
value.length <= 200 && /^[A-Za-z0-9][A-Za-z0-9._:/+-]*$/.test(value) && !value.includes("..");
|
|
2489
|
+
}
|
|
2490
|
+
if (!valid) {
|
|
2491
|
+
throw setupError({
|
|
2492
|
+
code: "RESEARCH_SETUP_SETTING_INVALID",
|
|
2493
|
+
step: "configuration",
|
|
2494
|
+
reason: `Setup setting failed ${validation} validation: ${id}.`,
|
|
2495
|
+
minimumAction: `Provide a non-secret ${validation} value for ${id}; URLs may not contain credentials, queries, or fragments.`,
|
|
2496
|
+
retryCommand: "tiangong-ai research setup plan --help",
|
|
2497
|
+
exitCode: 2,
|
|
2498
|
+
});
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
function setupMutations(root, targets, selected, credentialSources) {
|
|
2502
|
+
const mutations = [
|
|
2503
|
+
{
|
|
2504
|
+
step: "workspace",
|
|
2505
|
+
target: workspacePaths(root).control,
|
|
2506
|
+
reason: "Initialize or verify the auditable research workspace control plane.",
|
|
2507
|
+
},
|
|
2508
|
+
];
|
|
2509
|
+
for (const target of targets) {
|
|
2510
|
+
for (const skill of selected) {
|
|
2511
|
+
mutations.push({
|
|
2512
|
+
step: "skill-install",
|
|
2513
|
+
target: join(target.root, skill.skillName),
|
|
2514
|
+
reason: `Copy pinned ${skill.id} bytes for ${target.agent}.`,
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
if (selected.some((skill) => skill.role === "evidence-capability")) {
|
|
2519
|
+
mutations.push({
|
|
2520
|
+
step: "capability-configuration",
|
|
2521
|
+
target: workspacePaths(root).capabilityDeclarations,
|
|
2522
|
+
reason: "Declare and lock the explicitly selected evidence capabilities.",
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
if (credentialSources.some((credential) => credential.storage === "broker")) {
|
|
2526
|
+
mutations.push({
|
|
2527
|
+
step: "credentials",
|
|
2528
|
+
target: workspacePaths(root).env,
|
|
2529
|
+
reason: "Store selected broker credentials in the owner-only workspace environment file.",
|
|
2530
|
+
});
|
|
2531
|
+
}
|
|
2532
|
+
if (credentialSources.some((credential) => credential.storage === "adapter")) {
|
|
2533
|
+
mutations.push({
|
|
2534
|
+
step: "credentials",
|
|
2535
|
+
target: workspacePaths(root).setupAdapterEnv,
|
|
2536
|
+
reason: "Store selected companion-adapter credentials in an owner-only file.",
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
return mutations.sort((left, right) => `${left.step}\0${left.target}`.localeCompare(`${right.step}\0${right.target}`));
|
|
2540
|
+
}
|
|
2541
|
+
function setupLockPayload(planSha256) {
|
|
2542
|
+
return {
|
|
2543
|
+
schemaVersion: 1,
|
|
2544
|
+
operation: "research.setup",
|
|
2545
|
+
planSha256,
|
|
2546
|
+
pid: process.pid,
|
|
2547
|
+
hostname: hostname(),
|
|
2548
|
+
acquiredAt: new Date().toISOString(),
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2551
|
+
async function assertNoSymlinkedExistingPath(path, boundary) {
|
|
2552
|
+
const target = resolve(path);
|
|
2553
|
+
const policyRoot = boundary === undefined ? dirname(target) : resolve(boundary);
|
|
2554
|
+
if (boundary !== undefined &&
|
|
2555
|
+
target !== policyRoot &&
|
|
2556
|
+
!target.startsWith(`${policyRoot}${sep}`)) {
|
|
2557
|
+
throw setupError({
|
|
2558
|
+
code: "RESEARCH_SETUP_PATH_INVALID",
|
|
2559
|
+
step: "path-validation",
|
|
2560
|
+
reason: "Setup target escapes its reviewed workspace boundary.",
|
|
2561
|
+
minimumAction: "Use a target contained by the selected workspace.",
|
|
2562
|
+
retryCommand: "tiangong-ai research setup status --json",
|
|
2563
|
+
exitCode: 3,
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
const relativeParts = target.slice(policyRoot.length).split(sep).filter(Boolean);
|
|
2567
|
+
let current = policyRoot;
|
|
2568
|
+
for (const part of ["", ...relativeParts]) {
|
|
2569
|
+
if (part)
|
|
2570
|
+
current = join(current, part);
|
|
2571
|
+
const info = await lstat(current).catch((error) => {
|
|
2572
|
+
if (error.code === "ENOENT")
|
|
2573
|
+
return undefined;
|
|
2574
|
+
throw error;
|
|
2575
|
+
});
|
|
2576
|
+
if (!info)
|
|
2577
|
+
continue;
|
|
2578
|
+
if (info.isSymbolicLink()) {
|
|
2579
|
+
throw setupError({
|
|
2580
|
+
code: "RESEARCH_SETUP_SYMLINK_BLOCKED",
|
|
2581
|
+
step: "path-validation",
|
|
2582
|
+
reason: `Setup will not follow a symbolic link in a mutation path: ${current}.`,
|
|
2583
|
+
minimumAction: "Choose a regular directory path or inspect and remove the indirection manually.",
|
|
2584
|
+
retryCommand: "tiangong-ai research setup status --json",
|
|
2585
|
+
exitCode: 3,
|
|
2586
|
+
});
|
|
2587
|
+
}
|
|
2588
|
+
if (current !== target && !info.isDirectory()) {
|
|
2589
|
+
throw setupError({
|
|
2590
|
+
code: "RESEARCH_SETUP_PATH_INVALID",
|
|
2591
|
+
step: "path-validation",
|
|
2592
|
+
reason: `A setup mutation path has a non-directory parent: ${current}.`,
|
|
2593
|
+
minimumAction: "Choose a regular directory path and retry.",
|
|
2594
|
+
retryCommand: "tiangong-ai research setup status --json",
|
|
2595
|
+
exitCode: 3,
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
function installerEnvironment(source) {
|
|
2601
|
+
const result = {};
|
|
2602
|
+
const exact = new Set([
|
|
2603
|
+
"PATH",
|
|
2604
|
+
"HOME",
|
|
2605
|
+
"TMPDIR",
|
|
2606
|
+
"TMP",
|
|
2607
|
+
"TEMP",
|
|
2608
|
+
"LANG",
|
|
2609
|
+
"LC_ALL",
|
|
2610
|
+
"SHELL",
|
|
2611
|
+
"USER",
|
|
2612
|
+
"LOGNAME",
|
|
2613
|
+
"HTTP_PROXY",
|
|
2614
|
+
"HTTPS_PROXY",
|
|
2615
|
+
"ALL_PROXY",
|
|
2616
|
+
"NO_PROXY",
|
|
2617
|
+
"http_proxy",
|
|
2618
|
+
"https_proxy",
|
|
2619
|
+
"all_proxy",
|
|
2620
|
+
"no_proxy",
|
|
2621
|
+
"SSL_CERT_FILE",
|
|
2622
|
+
"SSL_CERT_DIR",
|
|
2623
|
+
"NODE_EXTRA_CA_CERTS",
|
|
2624
|
+
"CODEX_HOME",
|
|
2625
|
+
"CLAUDE_CONFIG_DIR",
|
|
2626
|
+
"VIRTUAL_ENV",
|
|
2627
|
+
]);
|
|
2628
|
+
for (const [key, value] of Object.entries(source)) {
|
|
2629
|
+
if (typeof value !== "string")
|
|
2630
|
+
continue;
|
|
2631
|
+
if ((exact.has(key) || key.startsWith("LC_")) && !isSensitiveEnvironmentName(key)) {
|
|
2632
|
+
result[key] = value;
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
result.PATH ??= process.env.PATH ?? "/usr/bin:/bin";
|
|
2636
|
+
result.HOME ??= homedir();
|
|
2637
|
+
result.DO_NOT_TRACK = "1";
|
|
2638
|
+
result.CI = "1";
|
|
2639
|
+
result.npm_config_yes = "true";
|
|
2640
|
+
result.npm_config_update_notifier = "false";
|
|
2641
|
+
result.npm_config_fund = "false";
|
|
2642
|
+
result.npm_config_audit = "false";
|
|
2643
|
+
return result;
|
|
2644
|
+
}
|
|
2645
|
+
function installerEnvironmentForTarget(plan, agent, source) {
|
|
2646
|
+
const result = installerEnvironment(source);
|
|
2647
|
+
if (plan.install.scope !== "global")
|
|
2648
|
+
return result;
|
|
2649
|
+
const target = plannedTargetRoot(plan, agent);
|
|
2650
|
+
if (agent === "codex") {
|
|
2651
|
+
// skills@1.5.22 treats Codex as a universal agent and installs globally to
|
|
2652
|
+
// $HOME/.agents/skills, independently of CODEX_HOME.
|
|
2653
|
+
result.HOME = dirname(dirname(target));
|
|
2654
|
+
delete result.CODEX_HOME;
|
|
2655
|
+
}
|
|
2656
|
+
else {
|
|
2657
|
+
// The upstream installer resolves Claude Code's global target from
|
|
2658
|
+
// CLAUDE_CONFIG_DIR/skills when that variable is present.
|
|
2659
|
+
result.CLAUDE_CONFIG_DIR = dirname(target);
|
|
2660
|
+
}
|
|
2661
|
+
return result;
|
|
2662
|
+
}
|
|
2663
|
+
function agentDoctorEnvironment(source) {
|
|
2664
|
+
const result = installerEnvironment(source);
|
|
2665
|
+
delete result.CI;
|
|
2666
|
+
return result;
|
|
2667
|
+
}
|
|
2668
|
+
function setupSecretValues(plan, environment) {
|
|
2669
|
+
return [
|
|
2670
|
+
...new Set([
|
|
2671
|
+
...configuredResearchSecrets(environment),
|
|
2672
|
+
...plan.credentialSources
|
|
2673
|
+
.map((credential) => environment[credential.fromEnvironment])
|
|
2674
|
+
.filter((value) => typeof value === "string" && value.length >= 8),
|
|
2675
|
+
]),
|
|
2676
|
+
];
|
|
2677
|
+
}
|
|
2678
|
+
function sanitizingSetupRunner(runner, secrets) {
|
|
2679
|
+
return async (input) => {
|
|
2680
|
+
const result = await runner(input);
|
|
2681
|
+
return {
|
|
2682
|
+
exitCode: result.exitCode,
|
|
2683
|
+
stdout: sanitizeResearchText(result.stdout, secrets),
|
|
2684
|
+
stderr: sanitizeResearchText(result.stderr, secrets),
|
|
2685
|
+
};
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2688
|
+
async function runSetupCommand(input) {
|
|
2689
|
+
return new Promise((resolvePromise) => {
|
|
2690
|
+
const secrets = configuredResearchSecrets(input.environment);
|
|
2691
|
+
let stdout = Buffer.alloc(0);
|
|
2692
|
+
let stderr = Buffer.alloc(0);
|
|
2693
|
+
let truncated = false;
|
|
2694
|
+
let settled = false;
|
|
2695
|
+
let timedOut = false;
|
|
2696
|
+
const child = spawn(input.command, input.args, {
|
|
2697
|
+
cwd: input.cwd,
|
|
2698
|
+
env: input.environment,
|
|
2699
|
+
shell: false,
|
|
2700
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2701
|
+
});
|
|
2702
|
+
const capture = (existing, chunk) => {
|
|
2703
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2704
|
+
const remaining = MAX_COMMAND_OUTPUT_BYTES - existing.length;
|
|
2705
|
+
if (remaining <= 0) {
|
|
2706
|
+
truncated = true;
|
|
2707
|
+
return existing;
|
|
2708
|
+
}
|
|
2709
|
+
if (bytes.length > remaining)
|
|
2710
|
+
truncated = true;
|
|
2711
|
+
return Buffer.concat([existing, bytes.subarray(0, remaining)]);
|
|
2712
|
+
};
|
|
2713
|
+
child.stdout?.on("data", (chunk) => {
|
|
2714
|
+
stdout = capture(stdout, chunk);
|
|
2715
|
+
if (truncated)
|
|
2716
|
+
child.kill("SIGTERM");
|
|
2717
|
+
});
|
|
2718
|
+
child.stderr?.on("data", (chunk) => {
|
|
2719
|
+
stderr = capture(stderr, chunk);
|
|
2720
|
+
if (truncated)
|
|
2721
|
+
child.kill("SIGTERM");
|
|
2722
|
+
});
|
|
2723
|
+
const timer = setTimeout(() => {
|
|
2724
|
+
timedOut = true;
|
|
2725
|
+
child.kill("SIGTERM");
|
|
2726
|
+
}, input.timeoutMs);
|
|
2727
|
+
timer.unref();
|
|
2728
|
+
const finish = (exitCode) => {
|
|
2729
|
+
if (settled)
|
|
2730
|
+
return;
|
|
2731
|
+
settled = true;
|
|
2732
|
+
clearTimeout(timer);
|
|
2733
|
+
const suffix = timedOut
|
|
2734
|
+
? "\n[command timed out]"
|
|
2735
|
+
: truncated
|
|
2736
|
+
? "\n[command output limit exceeded]"
|
|
2737
|
+
: "";
|
|
2738
|
+
resolvePromise({
|
|
2739
|
+
exitCode: timedOut || truncated ? 124 : exitCode,
|
|
2740
|
+
stdout: sanitizeResearchText(stdout.toString("utf8"), secrets),
|
|
2741
|
+
stderr: sanitizeResearchText(`${stderr.toString("utf8")}${suffix}`, secrets),
|
|
2742
|
+
});
|
|
2743
|
+
};
|
|
2744
|
+
child.on("error", (error) => {
|
|
2745
|
+
stderr = capture(stderr, error.message);
|
|
2746
|
+
finish(127);
|
|
2747
|
+
});
|
|
2748
|
+
child.on("close", (code) => finish(code ?? 1));
|
|
2749
|
+
});
|
|
2750
|
+
}
|
|
2751
|
+
async function runChecked(runner, command, args, cwd, environment, step, timeoutMs = 60_000) {
|
|
2752
|
+
const result = await runner({ command, args, cwd, environment, timeoutMs });
|
|
2753
|
+
if (result.exitCode !== 0)
|
|
2754
|
+
throw commandFailure(step, command, result, cwd, environment);
|
|
2755
|
+
return result;
|
|
2756
|
+
}
|
|
2757
|
+
function commandFailure(step, command, result, root, environment) {
|
|
2758
|
+
const diagnostic = sanitizeResearchText(result.stderr || result.stdout, configuredResearchSecrets(environment))
|
|
2759
|
+
.trim()
|
|
2760
|
+
.slice(0, 1_000);
|
|
2761
|
+
return setupError({
|
|
2762
|
+
code: "RESEARCH_SETUP_COMMAND_FAILED",
|
|
2763
|
+
step,
|
|
2764
|
+
reason: `${command} exited with status ${result.exitCode}.`,
|
|
2765
|
+
minimumAction: diagnostic
|
|
2766
|
+
? `Resolve the reported command failure (${diagnostic}), then retry the recorded step.`
|
|
2767
|
+
: "Resolve the command availability or network failure, then retry the recorded step.",
|
|
2768
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
2769
|
+
exitCode: 3,
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2772
|
+
async function setupDoctorCheck(checks, id, category, callback) {
|
|
2773
|
+
try {
|
|
2774
|
+
checks.push({
|
|
2775
|
+
id,
|
|
2776
|
+
category,
|
|
2777
|
+
status: "pass",
|
|
2778
|
+
detail: sanitizeResearchText(await callback()),
|
|
2779
|
+
minimumAction: null,
|
|
2780
|
+
});
|
|
2781
|
+
}
|
|
2782
|
+
catch (error) {
|
|
2783
|
+
checks.push({
|
|
2784
|
+
id,
|
|
2785
|
+
category,
|
|
2786
|
+
status: "fail",
|
|
2787
|
+
detail: sanitizeResearchText(error instanceof Error ? error.message : String(error)),
|
|
2788
|
+
minimumAction: `Resolve ${id}, then rerun research setup doctor.`,
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
async function appendDependencyChecks(checks, selected, runner, root, environment) {
|
|
2793
|
+
const dependencies = [
|
|
2794
|
+
...new Map(selected
|
|
2795
|
+
.flatMap((skill) => skill.dependencies)
|
|
2796
|
+
.map((dependency) => [dependency.id, dependency])).values(),
|
|
2797
|
+
].sort((left, right) => left.id.localeCompare(right.id));
|
|
2798
|
+
for (const dependency of dependencies) {
|
|
2799
|
+
if (dependency.kind === "manual") {
|
|
2800
|
+
checks.push({
|
|
2801
|
+
id: `dependency.${dependency.id}`,
|
|
2802
|
+
category: "dependency",
|
|
2803
|
+
status: "warn",
|
|
2804
|
+
detail: `${dependency.requirement}; setup intentionally does not install or resolve it.`,
|
|
2805
|
+
minimumAction: dependency.minimumAction,
|
|
2806
|
+
});
|
|
2807
|
+
continue;
|
|
2808
|
+
}
|
|
2809
|
+
await setupDoctorCheck(checks, `dependency.${dependency.id}`, "dependency", async () => {
|
|
2810
|
+
if (dependency.id === "python-3.10") {
|
|
2811
|
+
const result = await runner({
|
|
2812
|
+
command: "python3",
|
|
2813
|
+
args: ["--version"],
|
|
2814
|
+
cwd: root,
|
|
2815
|
+
environment: installerEnvironment(environment),
|
|
2816
|
+
timeoutMs: 15_000,
|
|
2817
|
+
});
|
|
2818
|
+
if (result.exitCode !== 0)
|
|
2819
|
+
throw new Error(dependency.minimumAction);
|
|
2820
|
+
const versionText = `${result.stdout} ${result.stderr}`;
|
|
2821
|
+
const match = versionText.match(/Python\s+(\d+)\.(\d+)(?:\.(\d+))?/i);
|
|
2822
|
+
if (!match || Number(match[1]) < 3 || (Number(match[1]) === 3 && Number(match[2]) < 10)) {
|
|
2823
|
+
throw new Error(`Detected ${versionText.trim() || "unknown Python version"}; ${dependency.requirement} is required.`);
|
|
2824
|
+
}
|
|
2825
|
+
return `${versionText.trim()} satisfies ${dependency.requirement}.`;
|
|
2826
|
+
}
|
|
2827
|
+
if (dependency.id === "academic-paper-download:pypdf") {
|
|
2828
|
+
const result = await runner({
|
|
2829
|
+
command: "python3",
|
|
2830
|
+
args: ["-c", "import importlib.metadata as m; print(m.version('pypdf'))"],
|
|
2831
|
+
cwd: root,
|
|
2832
|
+
environment: installerEnvironment(environment),
|
|
2833
|
+
timeoutMs: 15_000,
|
|
2834
|
+
});
|
|
2835
|
+
const observed = result.stdout.trim();
|
|
2836
|
+
if (result.exitCode !== 0 || observed !== "6.14.2") {
|
|
2837
|
+
throw new Error(`${dependency.requirement} is not active in the selected python3 environment.`);
|
|
2838
|
+
}
|
|
2839
|
+
return `${dependency.requirement} is active.`;
|
|
2840
|
+
}
|
|
2841
|
+
throw new Error(`No automatic dependency check is declared for ${dependency.id}. ${dependency.minimumAction}`);
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
async function appendCompanionLiveChecks(checks, input) {
|
|
2846
|
+
void input.sleeper;
|
|
2847
|
+
if (input.selected.some((skill) => skill.id === "tiangong.academic-paper-download")) {
|
|
2848
|
+
await setupDoctorCheck(checks, "live.semantic-scholar", "live-check", async () => {
|
|
2849
|
+
const apiKey = input.adapterCredentials.get("semantic-scholar.api-key");
|
|
2850
|
+
const headers = new Headers({ Accept: "application/json" });
|
|
2851
|
+
if (apiKey)
|
|
2852
|
+
headers.set("x-api-key", apiKey);
|
|
2853
|
+
const response = await input.fetcher("https://api.semanticscholar.org/graph/v1/paper/search?query=reproducible%20research&limit=1&fields=paperId", {
|
|
2854
|
+
method: "GET",
|
|
2855
|
+
headers,
|
|
2856
|
+
redirect: "manual",
|
|
2857
|
+
signal: AbortSignal.timeout(30_000),
|
|
2858
|
+
});
|
|
2859
|
+
if (response.status >= 300 && response.status < 400) {
|
|
2860
|
+
throw new Error("Semantic Scholar returned a redirect; credential-bearing redirects are not followed.");
|
|
2861
|
+
}
|
|
2862
|
+
if (!response.ok) {
|
|
2863
|
+
const detail = await boundedResponseText(response, 2_000, apiKey ? [apiKey] : []);
|
|
2864
|
+
throw new Error(`Semantic Scholar live check returned HTTP ${response.status}${detail ? `: ${detail}` : ""}.`);
|
|
2865
|
+
}
|
|
2866
|
+
await response.body?.cancel().catch(() => undefined);
|
|
2867
|
+
return apiKey
|
|
2868
|
+
? "Semantic Scholar accepted the configured optional API key."
|
|
2869
|
+
: "Semantic Scholar public API is reachable without an optional API key.";
|
|
2870
|
+
});
|
|
2871
|
+
}
|
|
2872
|
+
if (input.selected.some((skill) => skill.id === "tiangong.document-granular-decompose")) {
|
|
2873
|
+
if (!input.allowSyntheticUnstructureUpload) {
|
|
2874
|
+
checks.push({
|
|
2875
|
+
id: "live.tiangong-unstructure",
|
|
2876
|
+
category: "live-check",
|
|
2877
|
+
status: "warn",
|
|
2878
|
+
detail: "Synthetic document upload was not explicitly authorized, so no document was sent.",
|
|
2879
|
+
minimumAction: "Rerun setup doctor with the separate synthetic-upload confirmation after reviewing service cost and data policy.",
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2882
|
+
else {
|
|
2883
|
+
await setupDoctorCheck(checks, "live.tiangong-unstructure", "live-check", async () => {
|
|
2884
|
+
const baseUrl = input.plan.settings["tiangong.unstructure.base-url"];
|
|
2885
|
+
const token = input.adapterCredentials.get("tiangong.unstructure.auth-token");
|
|
2886
|
+
if (!baseUrl || !token)
|
|
2887
|
+
throw new Error("Unstructure URL or owner-only credential is missing.");
|
|
2888
|
+
const form = new FormData();
|
|
2889
|
+
form.set("file", new Blob([syntheticPdfText()], { type: "application/pdf" }), "tiangong-setup-doctor.pdf");
|
|
2890
|
+
const provider = input.plan.settings["tiangong.unstructure.provider"];
|
|
2891
|
+
const model = input.plan.settings["tiangong.unstructure.model"];
|
|
2892
|
+
if (provider)
|
|
2893
|
+
form.set("provider", provider);
|
|
2894
|
+
if (model)
|
|
2895
|
+
form.set("model", model);
|
|
2896
|
+
const response = await input.fetcher(`${baseUrl.replace(/\/+$/, "")}/mineru_with_images?return_txt=true`, {
|
|
2897
|
+
method: "POST",
|
|
2898
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
|
2899
|
+
body: form,
|
|
2900
|
+
redirect: "manual",
|
|
2901
|
+
signal: AbortSignal.timeout(120_000),
|
|
2902
|
+
});
|
|
2903
|
+
if (response.status >= 300 && response.status < 400) {
|
|
2904
|
+
throw new Error("Unstructure returned a redirect; Authorization is never forwarded.");
|
|
2905
|
+
}
|
|
2906
|
+
if (!response.ok) {
|
|
2907
|
+
const detail = await boundedResponseText(response, 2_000, [token]);
|
|
2908
|
+
throw new Error(`Unstructure live check returned HTTP ${response.status}${detail ? `: ${detail}` : ""}.`);
|
|
2909
|
+
}
|
|
2910
|
+
await response.body?.cancel().catch(() => undefined);
|
|
2911
|
+
return "Unstructure accepted and processed the explicitly authorized synthetic PDF.";
|
|
2912
|
+
});
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
async function boundedResponseText(response, maximumBytes, secrets) {
|
|
2917
|
+
if (!response.body)
|
|
2918
|
+
return "";
|
|
2919
|
+
const reader = response.body.getReader();
|
|
2920
|
+
const chunks = [];
|
|
2921
|
+
let bytes = 0;
|
|
2922
|
+
try {
|
|
2923
|
+
while (bytes < maximumBytes) {
|
|
2924
|
+
const next = await reader.read();
|
|
2925
|
+
if (next.done)
|
|
2926
|
+
break;
|
|
2927
|
+
const remaining = maximumBytes - bytes;
|
|
2928
|
+
const chunk = next.value.subarray(0, remaining);
|
|
2929
|
+
chunks.push(chunk);
|
|
2930
|
+
bytes += chunk.length;
|
|
2931
|
+
if (next.value.length > remaining)
|
|
2932
|
+
break;
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
finally {
|
|
2936
|
+
await reader.cancel().catch(() => undefined);
|
|
2937
|
+
}
|
|
2938
|
+
return sanitizeResearchText(Buffer.concat(chunks).toString("utf8"), secrets)
|
|
2939
|
+
.replace(/\s+/g, " ")
|
|
2940
|
+
.trim();
|
|
2941
|
+
}
|
|
2942
|
+
function syntheticPdfText() {
|
|
2943
|
+
const objects = [
|
|
2944
|
+
"<< /Type /Catalog /Pages 2 0 R >>",
|
|
2945
|
+
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
2946
|
+
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 144] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
|
|
2947
|
+
"<< /Length 53 >>\nstream\nBT /F1 12 Tf 36 72 Td (Tiangong setup doctor) Tj ET\nendstream",
|
|
2948
|
+
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
2949
|
+
];
|
|
2950
|
+
let pdf = "%PDF-1.4\n";
|
|
2951
|
+
const offsets = [0];
|
|
2952
|
+
for (let index = 0; index < objects.length; index += 1) {
|
|
2953
|
+
offsets.push(Buffer.byteLength(pdf, "ascii"));
|
|
2954
|
+
pdf += `${index + 1} 0 obj\n${objects[index]}\nendobj\n`;
|
|
2955
|
+
}
|
|
2956
|
+
const xref = Buffer.byteLength(pdf, "ascii");
|
|
2957
|
+
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
|
2958
|
+
for (const offset of offsets.slice(1)) {
|
|
2959
|
+
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
|
2960
|
+
}
|
|
2961
|
+
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
|
2962
|
+
return pdf;
|
|
2963
|
+
}
|
|
2964
|
+
function setupFailure(error, fallbackStep, root) {
|
|
2965
|
+
if (error instanceof CliError && isObject(error.details)) {
|
|
2966
|
+
const details = sanitizeResearchRecord(error.details);
|
|
2967
|
+
return {
|
|
2968
|
+
code: error.code,
|
|
2969
|
+
step: typeof details.step === "string" ? details.step : fallbackStep,
|
|
2970
|
+
reason: typeof details.reason === "string" ? details.reason : sanitizeResearchText(error.message),
|
|
2971
|
+
minimumAction: typeof details.minimumAction === "string"
|
|
2972
|
+
? details.minimumAction
|
|
2973
|
+
: "Resolve the reported setup error and retry the exact recorded step.",
|
|
2974
|
+
retryCommand: typeof details.retryCommand === "string"
|
|
2975
|
+
? details.retryCommand
|
|
2976
|
+
: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
return {
|
|
2980
|
+
code: "RESEARCH_SETUP_UNEXPECTED_FAILURE",
|
|
2981
|
+
step: fallbackStep,
|
|
2982
|
+
reason: sanitizeResearchText(error instanceof Error ? error.message : String(error)),
|
|
2983
|
+
minimumAction: "Inspect the sanitized setup status, correct the failure, and retry the exact recorded step.",
|
|
2984
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
function setupError(input) {
|
|
2988
|
+
const details = sanitizeResearchRecord({
|
|
2989
|
+
step: input.step,
|
|
2990
|
+
reason: input.reason,
|
|
2991
|
+
minimumAction: input.minimumAction,
|
|
2992
|
+
retryCommand: input.retryCommand,
|
|
2993
|
+
});
|
|
2994
|
+
return new CliError(sanitizeResearchText(input.reason), {
|
|
2995
|
+
code: input.code,
|
|
2996
|
+
exitCode: input.exitCode,
|
|
2997
|
+
details,
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
//# sourceMappingURL=setup.js.map
|