@aiwg/cli 2026.8.15 → 2026.8.17
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/dist/src/artifacts/browser-export.js +1 -0
- package/dist/src/artifacts/cli.js +1 -1
- package/dist/src/artifacts/fortemi-core-query-adapter.js +6 -0
- package/dist/src/artifacts/index-builder.js +4 -64
- package/dist/src/artifacts/index-files.js +80 -0
- package/dist/src/artifacts/stats.js +22 -50
- package/dist/src/artifacts/types.js +73 -1
- package/dist/src/cli/agent-spawn.js +4 -2
- package/dist/src/cli/handlers/cockpit.js +41 -0
- package/dist/src/cli/handlers/help.js +1 -1
- package/dist/src/cli/handlers/ralph.js +2 -1
- package/dist/src/cli/handlers/sdlc-accelerate.js +2 -1
- package/dist/src/cli/handlers/use.js +117 -12
- package/dist/src/cli/handlers/workspace.js +24 -2
- package/dist/src/cli/services/deployment-verification.js +9 -2
- package/dist/src/cockpit/doctor.js +257 -0
- package/dist/src/config/aiwg-config.js +36 -3
- package/dist/src/providers/transformation-receipt-integration.js +130 -3
- package/dist/src/security/artifact-verifier.js +7 -1
- package/dist/src/serve/pty-bridge.js +6 -11
- package/dist/src/skills/run.js +15 -6
- package/package.json +2 -1
|
@@ -52,7 +52,7 @@ import { generate as generateContextFiles, discoverDeployedArtifacts, } from '..
|
|
|
52
52
|
import { verifyModelWrapperDeployment } from '../../models/wrapper-deployment.js';
|
|
53
53
|
import { loadGraphIndexFile } from '../../artifacts/index-reader.js';
|
|
54
54
|
import { aggregateUseDeploymentResult, buildDryRunUseResult, renderUseDeploymentResult, verifyProviderDeployment, } from '../services/deployment-verification.js';
|
|
55
|
-
import { finalizeProviderTransformationReceipt, sourceVerificationsFromSignedWebRelease, } from '../../providers/transformation-receipt-integration.js';
|
|
55
|
+
import { finalizeProviderTransformationReceipt, providerReceiptHasLocalSources, sourceVerificationsFromSignedWebRelease, } from '../../providers/transformation-receipt-integration.js';
|
|
56
56
|
import { loadResourceTrustRootFile, resolveWebRelease, } from '../../resources/web-release.js';
|
|
57
57
|
import { createResourceCredentialProvider } from '../../auth/resource-credentials.js';
|
|
58
58
|
/**
|
|
@@ -75,10 +75,16 @@ function providerReceiptWebReleaseOptions() {
|
|
|
75
75
|
: {}),
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
function releaseResourceUnavailable(error) {
|
|
79
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
80
|
+
return /fetch failed|request timed out|no fetch implementation/i.test(message);
|
|
81
|
+
}
|
|
82
|
+
export async function resolveProviderReceiptSource(options) {
|
|
83
|
+
if (await providerReceiptHasLocalSources(options))
|
|
84
|
+
return { sourceDisposition: 'local-source' };
|
|
79
85
|
const versionInfo = await getVersionInfo();
|
|
80
86
|
if (versionInfo.devMode)
|
|
81
|
-
return
|
|
87
|
+
return { sourceDisposition: 'local-source' };
|
|
82
88
|
const releaseOptions = providerReceiptWebReleaseOptions();
|
|
83
89
|
let release;
|
|
84
90
|
try {
|
|
@@ -90,15 +96,24 @@ async function signedProviderSourceVerifications(options) {
|
|
|
90
96
|
// Protected production resources require the authenticated release
|
|
91
97
|
// credential. A configured alternate endpoint may intentionally be public.
|
|
92
98
|
if (!token && releaseOptions.baseUrl === undefined)
|
|
93
|
-
return
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
return { sourceDisposition: 'source-unavailable' };
|
|
100
|
+
try {
|
|
101
|
+
release = await resolveWebRelease({
|
|
102
|
+
...releaseOptions,
|
|
103
|
+
selector: versionInfo.version,
|
|
104
|
+
credentialProvider: async () => token,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (releaseResourceUnavailable(error))
|
|
109
|
+
return { sourceDisposition: 'source-unavailable' };
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
99
112
|
}
|
|
100
113
|
const verifications = await sourceVerificationsFromSignedWebRelease(options, release);
|
|
101
|
-
return Object.keys(verifications).length > 0
|
|
114
|
+
return Object.keys(verifications).length > 0
|
|
115
|
+
? { sourceVerifications: verifications }
|
|
116
|
+
: { sourceDisposition: 'verification-failed' };
|
|
102
117
|
}
|
|
103
118
|
/**
|
|
104
119
|
* Framework name to deploy mode mapping.
|
|
@@ -1005,6 +1020,79 @@ async function countBundleDeployedArtifacts(bundlePath, target, provider) {
|
|
|
1005
1020
|
rules: await countDeployedBundleFiles(bundlePath, 'rules', target, paths.rules, ['.md', '.mdc']),
|
|
1006
1021
|
};
|
|
1007
1022
|
}
|
|
1023
|
+
const SKILL_SUPPORT_REFERENCE = /(?:^|[\s`('"\[])((?:templates|references|scripts|assets)\/[A-Za-z0-9._@/+\-]+)(?=$|[\s`)'"\],:;])/gm;
|
|
1024
|
+
/**
|
|
1025
|
+
* Project skill-relative support files may live beside the skill or at the
|
|
1026
|
+
* bundle root (plugin payloads commonly share report templates). Materialize
|
|
1027
|
+
* only paths explicitly named by SKILL.md, and fail closed on missing or
|
|
1028
|
+
* unsafe sources so a deployed instruction can never point at absent assets.
|
|
1029
|
+
*/
|
|
1030
|
+
async function reconcileProjectLocalSkillAssets(bundlePath, target, provider) {
|
|
1031
|
+
const skillsRoot = path.join(bundlePath, 'skills');
|
|
1032
|
+
let skillDirs;
|
|
1033
|
+
try {
|
|
1034
|
+
skillDirs = (await fs.readdir(skillsRoot, { withFileTypes: true }))
|
|
1035
|
+
.filter(entry => entry.isDirectory())
|
|
1036
|
+
.map(entry => entry.name);
|
|
1037
|
+
}
|
|
1038
|
+
catch {
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
const paths = getProviderPaths(provider);
|
|
1042
|
+
const kernelSkillsPath = getProviderKernelSkillsPath(provider);
|
|
1043
|
+
const deployRoots = [...new Set([
|
|
1044
|
+
paths.skills,
|
|
1045
|
+
kernelSkillsPath,
|
|
1046
|
+
].filter((value) => Boolean(value)).map(value => resolveDeployPath(target, value)))];
|
|
1047
|
+
for (const skillName of skillDirs) {
|
|
1048
|
+
const sourceSkillDir = path.join(skillsRoot, skillName);
|
|
1049
|
+
const sourceSkillMd = path.join(sourceSkillDir, 'SKILL.md');
|
|
1050
|
+
let content;
|
|
1051
|
+
try {
|
|
1052
|
+
content = await fs.readFile(sourceSkillMd, 'utf8');
|
|
1053
|
+
}
|
|
1054
|
+
catch {
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
const references = [...new Set([...content.matchAll(SKILL_SUPPORT_REFERENCE)].map(match => match[1]))];
|
|
1058
|
+
for (const relative of references) {
|
|
1059
|
+
const normalized = path.posix.normalize(relative);
|
|
1060
|
+
if (normalized !== relative || normalized.startsWith('../') || path.isAbsolute(normalized)) {
|
|
1061
|
+
throw new Error(`unsafe skill support reference '${relative}' in ${sourceSkillMd}`);
|
|
1062
|
+
}
|
|
1063
|
+
const candidates = [path.join(sourceSkillDir, normalized), path.join(bundlePath, normalized)];
|
|
1064
|
+
let source;
|
|
1065
|
+
for (const candidate of candidates) {
|
|
1066
|
+
try {
|
|
1067
|
+
const stat = await fs.lstat(candidate);
|
|
1068
|
+
if (stat.isFile() && !stat.isSymbolicLink()) {
|
|
1069
|
+
source = candidate;
|
|
1070
|
+
break;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
catch { /* try bundle-root fallback */ }
|
|
1074
|
+
}
|
|
1075
|
+
if (!source)
|
|
1076
|
+
throw new Error(`missing skill support asset '${relative}' referenced by ${sourceSkillMd}`);
|
|
1077
|
+
let deployedSkillRoot;
|
|
1078
|
+
for (const root of deployRoots) {
|
|
1079
|
+
// The deployer may select the bulk or kernel tier; use the tier that
|
|
1080
|
+
// actually contains this skill's transformed SKILL.md.
|
|
1081
|
+
if (await fileExists(path.join(root, skillName, 'SKILL.md'))) {
|
|
1082
|
+
deployedSkillRoot = root;
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (!deployedSkillRoot)
|
|
1087
|
+
throw new Error(`deployed skill '${skillName}' not found while reconciling support assets`);
|
|
1088
|
+
const destination = path.join(deployedSkillRoot, skillName, ...normalized.split('/'));
|
|
1089
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
1090
|
+
await fs.copyFile(source, destination);
|
|
1091
|
+
const mode = (await fs.stat(source)).mode & 0o777;
|
|
1092
|
+
await fs.chmod(destination, mode);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1008
1096
|
/**
|
|
1009
1097
|
* Deploy a single project-local bundle to one provider via deploy-agents.mjs.
|
|
1010
1098
|
* Runs the same script and flags used for upstream addons, with the bundle
|
|
@@ -1072,6 +1160,15 @@ async function deployOneProjectLocalBundle(opts) {
|
|
|
1072
1160
|
env: { AIWG_ROOT: frameworkRoot },
|
|
1073
1161
|
});
|
|
1074
1162
|
exitCode = result.exitCode;
|
|
1163
|
+
if (exitCode === 0 && !dryRun) {
|
|
1164
|
+
try {
|
|
1165
|
+
await reconcileProjectLocalSkillAssets(bundle.artifactPath, target, provider);
|
|
1166
|
+
}
|
|
1167
|
+
catch (error) {
|
|
1168
|
+
ui.warn(`Project-local skill asset deployment failed for '${bundle.id}': ${error.message}`);
|
|
1169
|
+
exitCode = 1;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1075
1172
|
}
|
|
1076
1173
|
if (exitCode === 0 && cliCommandCount > 0) {
|
|
1077
1174
|
try {
|
|
@@ -2017,10 +2114,18 @@ export class UseHandler {
|
|
|
2017
2114
|
scope: effectiveScope,
|
|
2018
2115
|
requestedBundles: [requestedBundle],
|
|
2019
2116
|
};
|
|
2020
|
-
const
|
|
2021
|
-
await finalizeProviderTransformationReceipt({ ...receiptOptions,
|
|
2117
|
+
const sourceResolution = await resolveProviderReceiptSource(receiptOptions);
|
|
2118
|
+
await finalizeProviderTransformationReceipt({ ...receiptOptions, ...sourceResolution });
|
|
2022
2119
|
}
|
|
2023
2120
|
catch (error) {
|
|
2121
|
+
await finalizeProviderTransformationReceipt({
|
|
2122
|
+
projectRoot: projectDir,
|
|
2123
|
+
frameworkRoot,
|
|
2124
|
+
provider,
|
|
2125
|
+
scope: effectiveScope,
|
|
2126
|
+
requestedBundles: [requestedBundle],
|
|
2127
|
+
sourceDisposition: 'verification-failed',
|
|
2128
|
+
}).catch(() => undefined);
|
|
2024
2129
|
originalConsole.warn(`Provider receipt finalization failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2025
2130
|
}
|
|
2026
2131
|
}
|
|
@@ -12,6 +12,23 @@ import { createScriptRunner } from './script-runner.js';
|
|
|
12
12
|
import { getFrameworkRoot } from '../../channel/manager.mjs';
|
|
13
13
|
import { maybePrintCommunityFooter } from '../../community/footer.js';
|
|
14
14
|
import { buildDeploymentStatusProbe } from '../services/deployment-verification.js';
|
|
15
|
+
import { detectScope } from '../scope-resolver.js';
|
|
16
|
+
function statusProjectRoot(args, fallback) {
|
|
17
|
+
const valueFlags = new Set(['--scope', '--provider', '--bundle']);
|
|
18
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
19
|
+
if (valueFlags.has(args[index])) {
|
|
20
|
+
index += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (!args[index].startsWith('-'))
|
|
24
|
+
return args[index];
|
|
25
|
+
}
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
28
|
+
function statusFlagValue(args, flag) {
|
|
29
|
+
const index = args.indexOf(flag);
|
|
30
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
31
|
+
}
|
|
15
32
|
/**
|
|
16
33
|
* Handler for workspace status command
|
|
17
34
|
*
|
|
@@ -31,8 +48,13 @@ export const statusHandler = {
|
|
|
31
48
|
aliases: ['-status', '--status'],
|
|
32
49
|
async execute(ctx) {
|
|
33
50
|
if (ctx.args.includes('--probe')) {
|
|
34
|
-
const projectRoot = ctx.args
|
|
35
|
-
const
|
|
51
|
+
const projectRoot = statusProjectRoot(ctx.args, ctx.cwd ?? process.cwd());
|
|
52
|
+
const scope = detectScope(ctx.args);
|
|
53
|
+
const probe = await buildDeploymentStatusProbe(projectRoot, ctx.frameworkRoot, {
|
|
54
|
+
scope,
|
|
55
|
+
provider: statusFlagValue(ctx.args, '--provider'),
|
|
56
|
+
bundle: statusFlagValue(ctx.args, '--bundle'),
|
|
57
|
+
});
|
|
36
58
|
return {
|
|
37
59
|
exitCode: probe.status === 'needs-repair' ? 1 : 0,
|
|
38
60
|
message: JSON.stringify(probe, null, 2),
|
|
@@ -142,6 +142,13 @@ const RECEIPT_DRIFT_POLICY = {
|
|
|
142
142
|
severity: 'advisory',
|
|
143
143
|
remediation: 'Re-run the same aiwg use command to establish provider transformation evidence.',
|
|
144
144
|
},
|
|
145
|
+
'policy-exempt': {
|
|
146
|
+
severity: 'info',
|
|
147
|
+
},
|
|
148
|
+
'source-evidence-unavailable': {
|
|
149
|
+
severity: 'advisory',
|
|
150
|
+
remediation: 'Run aiwg auth login, then aiwg versions resolve <installed-version> once online to warm the verified cache; re-run the same aiwg use command afterward.',
|
|
151
|
+
},
|
|
145
152
|
};
|
|
146
153
|
async function collectProviderReceiptFindings(options, provider) {
|
|
147
154
|
try {
|
|
@@ -497,8 +504,8 @@ export async function verifyConfiguredDeployments(projectRoot, filters = {}, fra
|
|
|
497
504
|
}
|
|
498
505
|
return aggregateUseDeploymentResult({ projectRoot, frameworkRoot, scope: filters.scope ?? 'project', requestedBundles: bundles, providers: results });
|
|
499
506
|
}
|
|
500
|
-
export async function buildDeploymentStatusProbe(projectRoot, frameworkRoot = process.env.AIWG_ROOT || projectRoot) {
|
|
501
|
-
const result = await verifyConfiguredDeployments(projectRoot,
|
|
507
|
+
export async function buildDeploymentStatusProbe(projectRoot, frameworkRoot = process.env.AIWG_ROOT || projectRoot, filters = {}) {
|
|
508
|
+
const result = await verifyConfiguredDeployments(projectRoot, filters, frameworkRoot);
|
|
502
509
|
const notConfigured = result.requestedBundles.length === 0
|
|
503
510
|
&& result.findings.length > 0
|
|
504
511
|
&& result.findings.every((item) => item.id === 'deployment-not-configured');
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
4
|
+
import { homedir, hostname, platform } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
const execFile = promisify(execFileCallback);
|
|
9
|
+
export const COCKPIT_DOCTOR_SCHEMA = 'aiwg.cockpit-doctor/v1';
|
|
10
|
+
function safeText(value) {
|
|
11
|
+
return String(value ?? '')
|
|
12
|
+
.replace(/((?:bearer|token|nonce|secret|password|authorization)\s*)[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
|
|
13
|
+
.replace(/([?#](?:token|nonce|secret|password|authorization)=)[^&#\s]+/gi, '$1[redacted]')
|
|
14
|
+
.slice(0, 240);
|
|
15
|
+
}
|
|
16
|
+
function evidence(values) {
|
|
17
|
+
return Object.fromEntries(Object.entries(values).map(([key, value]) => [
|
|
18
|
+
key,
|
|
19
|
+
typeof value === 'string' ? safeText(value) : value,
|
|
20
|
+
]));
|
|
21
|
+
}
|
|
22
|
+
function row(id, status, code, summary, values, recovery) {
|
|
23
|
+
return { id, status, code, summary, evidence: evidence(values), recovery: status === 'pass' ? null : recovery };
|
|
24
|
+
}
|
|
25
|
+
function overall(rows) {
|
|
26
|
+
if (rows.some(item => item.status === 'blocked'))
|
|
27
|
+
return 'blocked';
|
|
28
|
+
if (rows.some(item => item.status === 'warn'))
|
|
29
|
+
return 'warn';
|
|
30
|
+
return 'pass';
|
|
31
|
+
}
|
|
32
|
+
function isLoopback(host) {
|
|
33
|
+
return ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(host.toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
function looksMock(body) {
|
|
36
|
+
const text = JSON.stringify({
|
|
37
|
+
service: body?.service,
|
|
38
|
+
name: body?.name,
|
|
39
|
+
executor: body?.executor,
|
|
40
|
+
implementation: body?.implementation,
|
|
41
|
+
mock: body?.mock,
|
|
42
|
+
}).toLowerCase();
|
|
43
|
+
return body?.mock === true || /mock[-_ ]?executor|cockpit[-_ ]?mock/.test(text);
|
|
44
|
+
}
|
|
45
|
+
function listenerRows(stdout) {
|
|
46
|
+
const lines = stdout.split(/\r?\n/).filter(line => /:(8120|8121|8122|8140)\b/.test(line));
|
|
47
|
+
const publicLines = lines.filter(line => /(?:0\.0\.0\.0|\[::\]|\*):(8120|8121|8122|8140)\b/.test(line));
|
|
48
|
+
if (publicLines.length > 0)
|
|
49
|
+
return row('listeners', 'blocked', 'public_bind', 'A Cockpit or executor listener is publicly bound.', { checked_ports: '8120-8122,8140', public_listener_count: publicLines.length }, 'Bind the named service to 127.0.0.1 and restart only that service.');
|
|
50
|
+
return row('listeners', lines.length > 0 ? 'pass' : 'warn', lines.length > 0 ? 'loopback_only' : 'listeners_not_observed', lines.length > 0 ? 'Observed application listeners are loopback-only.' : 'No application listeners were observed locally.', { checked_ports: '8120-8122,8140', observed_listener_count: lines.length }, 'Start the expected user services, then rerun the doctor.');
|
|
51
|
+
}
|
|
52
|
+
export function defaultCockpitDoctorProbes(cockpitPackageRoot) {
|
|
53
|
+
return {
|
|
54
|
+
async readRuntime(file) {
|
|
55
|
+
const [raw, info] = await Promise.all([readFile(file, 'utf8'), stat(file)]);
|
|
56
|
+
const record = JSON.parse(raw);
|
|
57
|
+
if (!record.token && record.token_ref && cockpitPackageRoot) {
|
|
58
|
+
try {
|
|
59
|
+
const keychain = await import(pathToFileURL(path.join(cockpitPackageRoot, 'shell-core', 'keychain.mjs')).href);
|
|
60
|
+
record.token = await keychain.readCockpitToken(record.token_ref);
|
|
61
|
+
}
|
|
62
|
+
catch { /* authentication row reports a focused failure */ }
|
|
63
|
+
}
|
|
64
|
+
return { record, mode: info.mode & 0o777, owned: info.uid === process.getuid?.() };
|
|
65
|
+
},
|
|
66
|
+
async fetchJson(url, headers = {}) {
|
|
67
|
+
const response = await fetch(url, { headers, signal: AbortSignal.timeout(2500) });
|
|
68
|
+
let body = null;
|
|
69
|
+
try {
|
|
70
|
+
body = await response.json();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
body = null;
|
|
74
|
+
}
|
|
75
|
+
return { status: response.status, body };
|
|
76
|
+
},
|
|
77
|
+
async command(command, args) {
|
|
78
|
+
try {
|
|
79
|
+
const result = await execFile(command, args, { timeout: 3000, maxBuffer: 1024 * 1024 });
|
|
80
|
+
return { ok: true, stdout: result.stdout };
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return { ok: false, stdout: typeof error?.stdout === 'string' ? error.stdout : '' };
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
pathExists: existsSync,
|
|
87
|
+
hostName: hostname,
|
|
88
|
+
platform,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export async function runCockpitDoctor(options, probes = defaultCockpitDoctorProbes(options.cockpitPackageRoot)) {
|
|
92
|
+
const topology = options.topology ?? 'same-host';
|
|
93
|
+
const cockpitHost = options.cockpitHost ?? probes.hostName();
|
|
94
|
+
const executorHost = options.executorHost ?? (topology === 'same-host' ? cockpitHost : 'unspecified');
|
|
95
|
+
const rows = [];
|
|
96
|
+
rows.push(options.cockpitInstalled
|
|
97
|
+
? row('package', options.cockpitVersion === options.coreVersion ? 'pass' : 'blocked', options.cockpitVersion === options.coreVersion ? 'version_lockstep' : 'version_skew', options.cockpitVersion === options.coreVersion ? 'Cockpit and AIWG versions match.' : 'Cockpit and AIWG versions differ.', {
|
|
98
|
+
core_version: options.coreVersion,
|
|
99
|
+
cockpit_version: options.cockpitVersion ?? 'unknown',
|
|
100
|
+
source: options.cockpitPackageRoot?.includes('node_modules') ? 'managed-package' : 'source-workspace',
|
|
101
|
+
location: options.cockpitPackageRoot?.includes('node_modules')
|
|
102
|
+
? '$AIWG_COCKPIT_HOME/node_modules/@aiwg/cockpit'
|
|
103
|
+
: 'apps/cockpit',
|
|
104
|
+
}, 'Run `aiwg use cockpit` to install the core-matched Cockpit package.')
|
|
105
|
+
: row('package', 'blocked', 'cockpit_not_installed', 'Cockpit is not installed.', { core_version: options.coreVersion, cockpit_version: null, source: 'absent' }, 'Run `aiwg use cockpit`.'));
|
|
106
|
+
const runtimeFile = options.runtimeFile ?? path.join(homedir(), '.aiwg', 'cockpit', 'runtime', 'bridge.json');
|
|
107
|
+
let runtime = null;
|
|
108
|
+
try {
|
|
109
|
+
const observed = await probes.readRuntime(runtimeFile);
|
|
110
|
+
runtime = observed.record;
|
|
111
|
+
const secure = observed.mode === 0o600 && observed.owned && Boolean(runtime.port) && Boolean(runtime.token || runtime.token_ref);
|
|
112
|
+
rows.push(row('bridge-runtime', secure ? 'pass' : 'blocked', secure ? 'runtime_secure' : 'runtime_insecure', secure ? 'Bridge runtime metadata has the required ownership and mode.' : 'Bridge runtime metadata is missing or insecure.', { mode: observed.mode.toString(8), owned_by_current_user: observed.owned, credential_present: Boolean(runtime.token || runtime.token_ref), port: runtime.port ?? null }, 'Stop Cockpit, restrict the runtime directory to 0700 and bridge.json to 0600, then restart Cockpit.'));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
rows.push(row('bridge-runtime', 'blocked', 'runtime_missing', 'Bridge runtime metadata is unavailable.', { runtime_file: 'default-cockpit-runtime', credential_present: false }, 'Start Cockpit as the intended user, then rerun the doctor.'));
|
|
116
|
+
}
|
|
117
|
+
let bridgeHealth = null;
|
|
118
|
+
if (runtime?.port) {
|
|
119
|
+
const base = `http://127.0.0.1:${runtime.port}`;
|
|
120
|
+
try {
|
|
121
|
+
const live = await probes.fetchJson(`${base}/healthz`);
|
|
122
|
+
if (live.status < 200 || live.status >= 300)
|
|
123
|
+
throw new Error('not live');
|
|
124
|
+
const token = typeof runtime.token === 'string' ? runtime.token : '';
|
|
125
|
+
const authed = await probes.fetchJson(`${base}/api/health`, token ? { authorization: `Bearer ${token}` } : {});
|
|
126
|
+
if ([401, 403].includes(authed.status)) {
|
|
127
|
+
rows.push(row('bridge', 'blocked', 'bridge_unauthenticated', 'Bridge is reachable but authentication failed.', { reachable: true, authenticated: false }, 'Restart the Bridge to mint fresh runtime credentials, then rerun the doctor.'));
|
|
128
|
+
}
|
|
129
|
+
else if (authed.status >= 200 && authed.status < 300) {
|
|
130
|
+
bridgeHealth = authed.body;
|
|
131
|
+
rows.push(row('bridge', 'pass', 'bridge_authenticated', 'Bridge is reachable and authenticated.', { reachable: true, authenticated: true, port: runtime.port }, null));
|
|
132
|
+
}
|
|
133
|
+
else
|
|
134
|
+
throw new Error('unexpected response');
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
rows.push(row('bridge', 'blocked', 'bridge_unreachable', 'Bridge is not reachable through its runtime endpoint.', { reachable: false, authenticated: false, port: runtime.port }, 'Restart the Cockpit user service and rerun the doctor.'));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
rows.push(row('bridge', 'blocked', 'bridge_unreachable', 'Bridge endpoint is unknown.', { reachable: false, authenticated: false }, 'Start Cockpit as the intended user, then rerun the doctor.'));
|
|
142
|
+
}
|
|
143
|
+
const executorUrlText = typeof bridgeHealth?.executor_url === 'string' ? bridgeHealth.executor_url : '';
|
|
144
|
+
if (executorUrlText) {
|
|
145
|
+
let executorUrl = null;
|
|
146
|
+
try {
|
|
147
|
+
executorUrl = new URL(executorUrlText);
|
|
148
|
+
}
|
|
149
|
+
catch { /* reported as unreachable */ }
|
|
150
|
+
const hostMatches = executorUrl && (topology === 'same-host'
|
|
151
|
+
? isLoopback(executorUrl.hostname)
|
|
152
|
+
: executorHost !== 'unspecified' && executorUrl.hostname === executorHost);
|
|
153
|
+
if (!hostMatches) {
|
|
154
|
+
rows.push(row('executor', 'blocked', 'wrong_host', 'Bridge targets a host inconsistent with the declared topology.', { topology, expected_host: executorHost, configured_host: executorUrl?.hostname ?? 'invalid' }, 'Correct the declared executor host or the Bridge executor URL; do not create a tunnel until they agree.'));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
try {
|
|
158
|
+
const bridgeExecutor = bridgeHealth?.executor;
|
|
159
|
+
const deep = bridgeExecutor && typeof bridgeExecutor === 'object'
|
|
160
|
+
? { status: bridgeExecutor.status === 'ok' ? 200 : 503, body: bridgeExecutor }
|
|
161
|
+
: await probes.fetchJson(`${executorUrlText.replace(/\/$/, '')}/healthz/deep`);
|
|
162
|
+
if ([401, 403].includes(deep.status)) {
|
|
163
|
+
rows.push(row('executor', 'blocked', 'executor_unauthenticated', 'Executor is reachable but authentication failed.', { reachable: true, authenticated: false, host_matches: true }, 'Configure the Bridge executor credential file with mode 0600, then restart the Bridge.'));
|
|
164
|
+
}
|
|
165
|
+
else if (deep.status < 200 || deep.status >= 300)
|
|
166
|
+
throw new Error('deep health failed');
|
|
167
|
+
else if (deep.body?.real_executor === false || looksMock(deep.body)) {
|
|
168
|
+
rows.push(row('executor', 'blocked', 'mock_executor', 'Configured executor identifies as a mock.', { reachable: true, authenticated: true, real_executor: false }, 'Point the Bridge at the real Agentic Sandbox executor and restart it without mock allowance.'));
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const observedVersion = safeText(deep.body?.version ?? deep.body?.commit ?? 'unknown');
|
|
172
|
+
const skew = Boolean(options.expectedExecutorVersion && observedVersion !== options.expectedExecutorVersion);
|
|
173
|
+
rows.push(row('executor', skew ? 'blocked' : 'pass', skew ? 'version_skew' : 'executor_ready', skew ? 'Executor identity does not match the expected version.' : 'Real executor deep health is ready.', { reachable: true, authenticated: true, real_executor: true, version_or_commit: observedVersion, auth_configured: Boolean(bridgeHealth.executor_auth_configured) }, 'Install or select the expected Agentic Sandbox release, then restart the executor and Bridge.'));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
rows.push(row('executor', 'blocked', 'executor_unreachable', 'Executor deep health is unreachable.', { reachable: false, host_matches: true }, 'Start the executor on the declared host and verify only its required transport before retrying.'));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
rows.push(row('executor', 'blocked', 'executor_unreachable', 'Bridge did not report an executor endpoint.', { reachable: false, host_matches: false }, 'Configure the Bridge executor URL and restart the Bridge.'));
|
|
183
|
+
}
|
|
184
|
+
rows.push(row('host-runtime', 'pass', 'host_ready', 'Host runtime is available.', { platform: probes.platform(), node: process.version }, null));
|
|
185
|
+
const docker = await probes.command('docker', ['info', '--format', '{{json .ServerVersion}}']);
|
|
186
|
+
rows.push(row('docker-runtime', docker.ok ? 'pass' : 'warn', docker.ok ? 'docker_ready' : 'docker_unavailable', docker.ok ? 'Docker runtime is reachable.' : 'Docker runtime is not reachable.', { ready: docker.ok }, 'Start Docker or choose the host runtime tier; host readiness is independent.'));
|
|
187
|
+
const kvm = probes.pathExists('/dev/kvm');
|
|
188
|
+
rows.push(row('vm-runtime', kvm ? 'pass' : 'warn', kvm ? 'kvm_ready' : 'kvm_unavailable', kvm ? 'KVM device is available.' : 'VM readiness is not claimed because KVM is unavailable.', { kvm_available: kvm }, 'Enable KVM access only if the VM runtime tier is required.'));
|
|
189
|
+
const listeners = await probes.command('ss', ['-ltn']);
|
|
190
|
+
rows.push(listeners.ok ? listenerRows(listeners.stdout) : row('listeners', 'warn', 'listener_inspection_unavailable', 'Listener inspection is unavailable.', { checked_ports: '8120-8122,8140' }, 'Install `ss` support or inspect these listeners locally, then rerun the doctor.'));
|
|
191
|
+
const cockpitEnabled = await probes.command('systemctl', ['--user', 'is-enabled', 'aiwg-cockpit.service']);
|
|
192
|
+
const executorEnabled = await probes.command('systemctl', ['--user', 'is-enabled', 'agentic-sandbox.service']);
|
|
193
|
+
const cockpitUnit = await probes.command('systemctl', ['--user', 'show', 'aiwg-cockpit.service', '--property=ActiveState,After,Requires,Restart']);
|
|
194
|
+
const executorUnit = await probes.command('systemctl', ['--user', 'show', 'agentic-sandbox.service', '--property=ActiveState,Restart']);
|
|
195
|
+
const linger = await probes.command('loginctl', ['show-user', process.env.USER ?? '', '--property=Linger', '--value']);
|
|
196
|
+
const dependencyOrdered = topology !== 'same-host' || /(?:After|Requires)=.*agentic-sandbox\.service/.test(cockpitUnit.stdout);
|
|
197
|
+
const restartReady = /Restart=(?:on-failure|always)/.test(cockpitUnit.stdout)
|
|
198
|
+
&& /Restart=(?:on-failure|always)/.test(executorUnit.stdout);
|
|
199
|
+
const active = /ActiveState=active/.test(cockpitUnit.stdout) && /ActiveState=active/.test(executorUnit.stdout);
|
|
200
|
+
const persistent = cockpitEnabled.ok && executorEnabled.ok && linger.ok && linger.stdout.trim() === 'yes'
|
|
201
|
+
&& cockpitUnit.ok && executorUnit.ok && dependencyOrdered && restartReady && active;
|
|
202
|
+
rows.push(row('persistence', persistent ? 'pass' : 'warn', persistent ? 'user_systemd_ready' : 'user_systemd_incomplete', persistent ? 'User services and linger support persistence.' : 'User-service persistence is incomplete or unavailable.', {
|
|
203
|
+
cockpit_enabled: cockpitEnabled.ok,
|
|
204
|
+
executor_enabled: executorEnabled.ok,
|
|
205
|
+
units_active: active,
|
|
206
|
+
linger_enabled: linger.stdout.trim() === 'yes',
|
|
207
|
+
dependency_order_ready: dependencyOrdered,
|
|
208
|
+
restart_recovery_ready: restartReady,
|
|
209
|
+
}, 'Enable only the Cockpit and executor user units, then enable linger for the service account.'));
|
|
210
|
+
const topologyValid = topology === 'same-host' ? cockpitHost === executorHost : executorHost !== 'unspecified';
|
|
211
|
+
if (topology !== 'same-host' && options.forwardEndpoint) {
|
|
212
|
+
try {
|
|
213
|
+
const endpoint = new URL(options.forwardEndpoint);
|
|
214
|
+
const forward = await probes.fetchJson(`${options.forwardEndpoint.replace(/\/$/, '')}/healthz`);
|
|
215
|
+
const safeEndpoint = `${endpoint.protocol}//${endpoint.hostname}:${endpoint.port || 'default'}`;
|
|
216
|
+
rows.push(row('ssh-forward', forward.status >= 200 && forward.status < 300 ? 'pass' : 'blocked', forward.status >= 200 && forward.status < 300 ? 'forward_ready' : 'forward_unreachable', forward.status >= 200 && forward.status < 300 ? 'Declared SSH forward reaches the expected service.' : 'Declared SSH forward is unreachable.', { kind: topology, endpoint: safeEndpoint, reachable: forward.status >= 200 && forward.status < 300 }, 'Correct only the declared SSH forward endpoint, then rerun the doctor.'));
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
rows.push(row('ssh-forward', 'blocked', 'forward_unreachable', 'Declared SSH forward is invalid or unreachable.', { kind: topology, reachable: false }, 'Correct only the declared SSH forward endpoint, then rerun the doctor.'));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
rows.push(row('topology', topologyValid ? 'pass' : 'blocked', topologyValid ? 'topology_declared' : 'topology_ambiguous', topologyValid ? 'Cockpit, executor, and operator access topology is explicit.' : 'Executor host is not explicitly declared.', { kind: topology, cockpit_host: cockpitHost, executor_host: executorHost, operator_access: topology === 'same-host' ? 'local' : topology === 'ssh-local' ? 'ssh-local-forward' : 'ssh-reverse-forward' }, 'Declare the executor host before generating or validating any forward.'));
|
|
223
|
+
return {
|
|
224
|
+
schema: COCKPIT_DOCTOR_SCHEMA,
|
|
225
|
+
generated_at: (options.now ?? (() => new Date()))().toISOString(),
|
|
226
|
+
topology: {
|
|
227
|
+
kind: topology,
|
|
228
|
+
cockpit_host: safeText(cockpitHost),
|
|
229
|
+
executor_host: safeText(executorHost),
|
|
230
|
+
operator_access: topology === 'same-host' ? 'local' : topology === 'ssh-local' ? 'ssh-local-forward' : 'ssh-reverse-forward',
|
|
231
|
+
},
|
|
232
|
+
status: overall(rows),
|
|
233
|
+
rows,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
export function formatCockpitDoctor(report, format) {
|
|
237
|
+
if (format === 'json')
|
|
238
|
+
return JSON.stringify(report, null, 2);
|
|
239
|
+
if (format === 'markdown') {
|
|
240
|
+
return [
|
|
241
|
+
`# Cockpit Connection Doctor`,
|
|
242
|
+
'',
|
|
243
|
+
`Status: **${report.status}** `,
|
|
244
|
+
`Topology: \`${report.topology.kind}\``,
|
|
245
|
+
'',
|
|
246
|
+
'| Check | Status | Code | Summary | Recovery |',
|
|
247
|
+
'|---|---|---|---|---|',
|
|
248
|
+
...report.rows.map(item => `| ${item.id} | ${item.status} | ${item.code} | ${item.summary} | ${item.recovery ?? '—'} |`),
|
|
249
|
+
].join('\n');
|
|
250
|
+
}
|
|
251
|
+
return [
|
|
252
|
+
`Cockpit connection doctor: ${report.status}`,
|
|
253
|
+
`Topology: ${report.topology.kind} (${report.topology.cockpit_host} -> ${report.topology.executor_host})`,
|
|
254
|
+
...report.rows.map(item => `${item.status.toUpperCase().padEnd(7)} ${item.id.padEnd(16)} ${item.code}: ${item.summary}${item.recovery ? ` Recovery: ${item.recovery}` : ''}`),
|
|
255
|
+
].join('\n');
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=doctor.js.map
|
|
@@ -158,13 +158,46 @@ export function validateIndexConfig(index) {
|
|
|
158
158
|
if (typeof index !== 'object' || Array.isArray(index)) {
|
|
159
159
|
return ['index: must be an object'];
|
|
160
160
|
}
|
|
161
|
-
const
|
|
161
|
+
const indexObject = index;
|
|
162
|
+
const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
163
|
+
const graphOverrides = indexObject.graphOverrides;
|
|
164
|
+
if (graphOverrides !== undefined) {
|
|
165
|
+
if (typeof graphOverrides !== 'object' || graphOverrides === null || Array.isArray(graphOverrides)) {
|
|
166
|
+
errors.push('index.graphOverrides: must be an object mapping supported built-in graph names to overrides');
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
for (const [name, rawOverride] of Object.entries(graphOverrides)) {
|
|
170
|
+
const where = `index.graphOverrides.${name}`;
|
|
171
|
+
if (name !== 'codebase') {
|
|
172
|
+
errors.push(`${where}: unsupported built-in graph override (supported: codebase)`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (typeof rawOverride !== 'object' || rawOverride === null || Array.isArray(rawOverride)) {
|
|
176
|
+
errors.push(`${where}: must be an object`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const override = rawOverride;
|
|
180
|
+
for (const field of Object.keys(override)) {
|
|
181
|
+
if (field !== 'scanDirs' && field !== 'extensions') {
|
|
182
|
+
errors.push(`${where}.${field}: unknown field (supported: scanDirs, extensions)`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (override.scanDirs !== undefined && (!isStringArray(override.scanDirs) || override.scanDirs.length === 0)) {
|
|
186
|
+
errors.push(`${where}.scanDirs: must be a non-empty array of strings`);
|
|
187
|
+
}
|
|
188
|
+
if (override.extensions !== undefined && (!isStringArray(override.extensions) || override.extensions.length === 0)) {
|
|
189
|
+
errors.push(`${where}.extensions: must be a non-empty array of strings`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const graphs = indexObject.graphs;
|
|
162
195
|
if (graphs === undefined)
|
|
163
196
|
return errors; // index with no graphs is permissible
|
|
164
197
|
if (typeof graphs !== 'object' || graphs === null || Array.isArray(graphs)) {
|
|
165
|
-
|
|
198
|
+
errors.push('index.graphs: must be an object mapping graph names to definitions');
|
|
199
|
+
return errors;
|
|
166
200
|
}
|
|
167
|
-
const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
168
201
|
for (const [name, rawDef] of Object.entries(graphs)) {
|
|
169
202
|
const where = `index.graphs.${name}`;
|
|
170
203
|
if (typeof rawDef !== 'object' || rawDef === null || Array.isArray(rawDef)) {
|