@hifullmoon/aicommit 1.4.0
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/.aicommit.config.example.json +118 -0
- package/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +406 -0
- package/README.zh-CN.md +408 -0
- package/SECURITY.md +33 -0
- package/bin/aicommit.js +47 -0
- package/docs/distribution.md +104 -0
- package/docs/examples/aicommit-policy.yml +24 -0
- package/docs/examples/commit-msg +4 -0
- package/docs/examples/extension/aicommit-extension.json +9 -0
- package/docs/examples/extension/index.mjs +32 -0
- package/docs/extensions.md +93 -0
- package/docs/privacy.md +58 -0
- package/docs/provider-compatibility.md +37 -0
- package/docs/provider-presets.md +100 -0
- package/docs/team-policy.md +86 -0
- package/docs/troubleshooting.md +37 -0
- package/package.json +100 -0
- package/presets/provider-presets.json +60 -0
- package/schemas/aicommit-extension.schema.json +27 -0
- package/schemas/aicommit-output.schema.json +86 -0
- package/schemas/aicommit-provider-presets.schema.json +56 -0
- package/schemas/aicommit-split-checkpoint.schema.json +92 -0
- package/schemas/aicommit-split-plan.schema.json +108 -0
- package/schemas/aicommit-team-policy.schema.json +59 -0
- package/src/api.js +740 -0
- package/src/cli.js +558 -0
- package/src/completion.js +136 -0
- package/src/config-command.js +59 -0
- package/src/config.js +524 -0
- package/src/context.js +537 -0
- package/src/credentials.js +123 -0
- package/src/doctor.js +131 -0
- package/src/errors.js +96 -0
- package/src/extension-runner.mjs +36 -0
- package/src/extensions.js +426 -0
- package/src/generation-ui.js +53 -0
- package/src/git.js +458 -0
- package/src/main.js +768 -0
- package/src/metrics.js +375 -0
- package/src/output.js +87 -0
- package/src/policy-command.js +172 -0
- package/src/policy.js +421 -0
- package/src/preset-command.js +91 -0
- package/src/provider-presets.js +361 -0
- package/src/providers.js +306 -0
- package/src/setup.js +268 -0
- package/src/split-checkpoint.js +252 -0
- package/src/split-hunks.js +263 -0
- package/src/split-plan.js +339 -0
- package/src/split.js +2285 -0
- package/src/team-policy.js +95 -0
- package/src/trust.js +31 -0
- package/src/ui.js +488 -0
- package/src/utils.js +165 -0
- package/templates/.aicommit.policy.json +22 -0
package/src/doctor.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
import { checkConnection } from './api.js';
|
|
6
|
+
import { loadConfig, isSecureApiUrl } from './config.js';
|
|
7
|
+
import { classifyError, ERROR_CATEGORIES, fail } from './errors.js';
|
|
8
|
+
import { getProviderAdapter } from './providers.js';
|
|
9
|
+
import { configureExtensionHost, resolveProviderAdapter } from './extensions.js';
|
|
10
|
+
import { formatMs, formatUsage, redactSensitiveUrl, sanitizeTerminalText } from './utils.js';
|
|
11
|
+
|
|
12
|
+
function nodeSupported(version = process.versions.node) {
|
|
13
|
+
return Number(version.split('.')[0]) >= 18;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function renderCheck(check) {
|
|
17
|
+
const icon = check.status === 'pass' ? chalk.green('✓') : check.status === 'warn' ? '⚠' : '✗';
|
|
18
|
+
const color =
|
|
19
|
+
check.status === 'pass' ? chalk.dim : check.status === 'warn' ? chalk.yellow : chalk.red;
|
|
20
|
+
console.log(` ${icon} ${color(`${check.name}: ${sanitizeTerminalText(check.message)}`)}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function addCheck(checks, name, status, message) {
|
|
24
|
+
const check = { name, status, message };
|
|
25
|
+
checks.push(check);
|
|
26
|
+
renderCheck(check);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function gitVersion() {
|
|
30
|
+
return execFileSync('git', ['--version'], {
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
33
|
+
}).trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runDoctor(cliProvider = null) {
|
|
37
|
+
const checks = [];
|
|
38
|
+
const warnings = [];
|
|
39
|
+
console.log('\n ' + chalk.cyan.bold('Doctor diagnostics'));
|
|
40
|
+
console.log(' ' + chalk.dim('─'.repeat(45)));
|
|
41
|
+
|
|
42
|
+
const nodeVersion = process.versions.node;
|
|
43
|
+
addCheck(
|
|
44
|
+
checks,
|
|
45
|
+
'Node.js',
|
|
46
|
+
nodeSupported(nodeVersion) ? 'pass' : 'fail',
|
|
47
|
+
`v${nodeVersion}${nodeSupported(nodeVersion) ? ' (supported)' : ' (requires >=18)'}`,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
addCheck(checks, 'Git', 'pass', gitVersion());
|
|
52
|
+
} catch (err) {
|
|
53
|
+
addCheck(checks, 'Git', 'fail', err.message);
|
|
54
|
+
const failure = fail(ERROR_CATEGORIES.GIT_STATE, `Git is unavailable: ${err.message}`, {
|
|
55
|
+
cause: err,
|
|
56
|
+
reported: true,
|
|
57
|
+
});
|
|
58
|
+
throw failure;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let loadedConfig;
|
|
62
|
+
try {
|
|
63
|
+
loadedConfig = await loadConfig(cliProvider);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const classified = classifyError(err);
|
|
66
|
+
addCheck(checks, 'Config', 'fail', classified.message);
|
|
67
|
+
classified.reported = true;
|
|
68
|
+
throw classified;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { config, loaded, providerName, credentialSourceLabel, credentialWarning } = loadedConfig;
|
|
72
|
+
const extensionHost = await configureExtensionHost(config);
|
|
73
|
+
const adapter = await resolveProviderAdapter(config, getProviderAdapter);
|
|
74
|
+
const provider = providerName || adapter.id;
|
|
75
|
+
addCheck(checks, 'Config', 'pass', loaded.length ? loaded.join(' + ') : 'built-in defaults');
|
|
76
|
+
addCheck(
|
|
77
|
+
checks,
|
|
78
|
+
'Endpoint security',
|
|
79
|
+
isSecureApiUrl(config.apiUrl) ? 'pass' : 'fail',
|
|
80
|
+
isSecureApiUrl(config.apiUrl)
|
|
81
|
+
? redactSensitiveUrl(config.apiUrl)
|
|
82
|
+
: 'insecure endpoint rejected',
|
|
83
|
+
);
|
|
84
|
+
addCheck(
|
|
85
|
+
checks,
|
|
86
|
+
'Provider capabilities',
|
|
87
|
+
'pass',
|
|
88
|
+
`${provider}; stream=${adapter.capabilities.streaming}, reasoning=${adapter.capabilities.reasoning}, ` +
|
|
89
|
+
`tokens=${adapter.capabilities.tokenBudget}, usage=${adapter.capabilities.usage}, ` +
|
|
90
|
+
`finishReason=${adapter.capabilities.finishReason}`,
|
|
91
|
+
);
|
|
92
|
+
addCheck(checks, 'Credentials', credentialWarning ? 'warn' : 'pass', credentialSourceLabel);
|
|
93
|
+
if (credentialWarning) warnings.push(credentialWarning);
|
|
94
|
+
if (extensionHost.extensions.length) {
|
|
95
|
+
addCheck(
|
|
96
|
+
checks,
|
|
97
|
+
'Extensions',
|
|
98
|
+
'pass',
|
|
99
|
+
extensionHost.extensions.map((item) => `${item.id}@${item.version}`).join(', '),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let report;
|
|
104
|
+
try {
|
|
105
|
+
report = await checkConnection(config);
|
|
106
|
+
const usage = report.usage ? `; tokens=${formatUsage(report.usage)}` : '';
|
|
107
|
+
addCheck(
|
|
108
|
+
checks,
|
|
109
|
+
'Connectivity',
|
|
110
|
+
'pass',
|
|
111
|
+
`${formatMs(report.elapsed)}; model=${report.model || config.modelId}${usage}`,
|
|
112
|
+
);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
const classified = classifyError(err);
|
|
115
|
+
addCheck(checks, 'Connectivity', 'fail', classified.message);
|
|
116
|
+
classified.reported = true;
|
|
117
|
+
throw classified;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log('\n ' + chalk.green.bold(`✓ Doctor passed (${checks.length} checks)\n`));
|
|
121
|
+
return {
|
|
122
|
+
message: `doctor: ${checks.length} checks passed`,
|
|
123
|
+
provider,
|
|
124
|
+
model: config.modelId,
|
|
125
|
+
latencyMs: report.elapsed,
|
|
126
|
+
usage: report.usage,
|
|
127
|
+
warnings,
|
|
128
|
+
exitReason: 'doctor_ok',
|
|
129
|
+
committed: false,
|
|
130
|
+
};
|
|
131
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export const ERROR_CATEGORIES = Object.freeze({
|
|
2
|
+
CONFIG: 'config',
|
|
3
|
+
GIT_STATE: 'git_state',
|
|
4
|
+
NETWORK: 'network',
|
|
5
|
+
PROVIDER: 'provider',
|
|
6
|
+
RESPONSE_FORMAT: 'response_format',
|
|
7
|
+
SENSITIVE_DATA: 'sensitive_data',
|
|
8
|
+
CONCURRENT_MODIFICATION: 'concurrent_modification',
|
|
9
|
+
INTERNAL: 'internal',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const EXIT_CODES = Object.freeze({
|
|
13
|
+
success: 0,
|
|
14
|
+
[ERROR_CATEGORIES.INTERNAL]: 1,
|
|
15
|
+
[ERROR_CATEGORIES.CONFIG]: 2,
|
|
16
|
+
[ERROR_CATEGORIES.GIT_STATE]: 3,
|
|
17
|
+
[ERROR_CATEGORIES.NETWORK]: 4,
|
|
18
|
+
[ERROR_CATEGORIES.PROVIDER]: 5,
|
|
19
|
+
[ERROR_CATEGORIES.RESPONSE_FORMAT]: 6,
|
|
20
|
+
[ERROR_CATEGORIES.SENSITIVE_DATA]: 7,
|
|
21
|
+
[ERROR_CATEGORIES.CONCURRENT_MODIFICATION]: 8,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const NETWORK_CODES = new Set([
|
|
25
|
+
'ECONNRESET',
|
|
26
|
+
'ECONNREFUSED',
|
|
27
|
+
'EHOSTUNREACH',
|
|
28
|
+
'ENETUNREACH',
|
|
29
|
+
'ENOTFOUND',
|
|
30
|
+
'ETIMEDOUT',
|
|
31
|
+
'EPIPE',
|
|
32
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
33
|
+
'UND_ERR_SOCKET',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export class AicommitError extends Error {
|
|
37
|
+
constructor(category, message, options = {}) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = 'AicommitError';
|
|
40
|
+
this.category = category;
|
|
41
|
+
this.exitCode = EXIT_CODES[category] ?? EXIT_CODES.internal;
|
|
42
|
+
this.reported = Boolean(options.reported);
|
|
43
|
+
if (options.data && typeof options.data === 'object' && !Array.isArray(options.data)) {
|
|
44
|
+
this.data = options.data;
|
|
45
|
+
}
|
|
46
|
+
if (options.cause !== undefined) this.cause = options.cause;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function fail(category, message, options) {
|
|
51
|
+
return new AicommitError(category, message, options);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function errorCode(err) {
|
|
55
|
+
return err?.code || err?.cause?.code || '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function classifyError(err) {
|
|
59
|
+
if (err instanceof AicommitError) return err;
|
|
60
|
+
|
|
61
|
+
const message = String(err?.message || err || 'Unknown error');
|
|
62
|
+
const lower = message.toLowerCase();
|
|
63
|
+
const code = errorCode(err);
|
|
64
|
+
if (
|
|
65
|
+
err instanceof TypeError ||
|
|
66
|
+
NETWORK_CODES.has(code) ||
|
|
67
|
+
/timed out|fetch failed|socket|network|dns|econn|enotfound/.test(lower)
|
|
68
|
+
) {
|
|
69
|
+
return fail(ERROR_CATEGORIES.NETWORK, message, { cause: err });
|
|
70
|
+
}
|
|
71
|
+
if (/^http \d{3}:|streaming api error|rate limit|provider request/.test(lower)) {
|
|
72
|
+
return fail(ERROR_CATEGORIES.PROVIDER, message, { cause: err });
|
|
73
|
+
}
|
|
74
|
+
if (
|
|
75
|
+
/invalid json|invalid conventional commit|empty commit message|empty split plan|failed to parse.*plan|provider returned an invalid response|no text came back/.test(
|
|
76
|
+
lower,
|
|
77
|
+
)
|
|
78
|
+
) {
|
|
79
|
+
return fail(ERROR_CATEGORIES.RESPONSE_FORMAT, message, { cause: err });
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
/invalid config|failed to (?:parse|read).*?(?:config|team policy)|environment variable|credential helper|unknown provider|provider.*defined|unknown option|missing value|unexpected extra argument|invalid reasoning level|not a valid directory|--output=json/.test(
|
|
83
|
+
lower,
|
|
84
|
+
)
|
|
85
|
+
) {
|
|
86
|
+
return fail(ERROR_CATEGORIES.CONFIG, message, { cause: err });
|
|
87
|
+
}
|
|
88
|
+
if (
|
|
89
|
+
/git|staged|working tree|nothing to commit|no changes to commit|not a git repository/.test(
|
|
90
|
+
lower,
|
|
91
|
+
)
|
|
92
|
+
) {
|
|
93
|
+
return fail(ERROR_CATEGORIES.GIT_STATE, message, { cause: err });
|
|
94
|
+
}
|
|
95
|
+
return fail(ERROR_CATEGORIES.INTERNAL, message, { cause: err });
|
|
96
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
const MAX_INPUT_CHARS = 256_000;
|
|
4
|
+
|
|
5
|
+
for (const method of ['log', 'info', 'warn', 'error']) {
|
|
6
|
+
console[method] = (...values) => {
|
|
7
|
+
process.stderr.write(`${values.map((value) => String(value)).join(' ')}\n`);
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function write(value) {
|
|
12
|
+
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let input = '';
|
|
16
|
+
process.stdin.setEncoding('utf8');
|
|
17
|
+
for await (const chunk of process.stdin) {
|
|
18
|
+
input += chunk;
|
|
19
|
+
if (input.length > MAX_INPUT_CHARS) throw new Error('Extension input exceeds 256000 characters.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const request = JSON.parse(input);
|
|
24
|
+
const source = await readFile(request.entry, 'utf8');
|
|
25
|
+
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`;
|
|
26
|
+
const extension = await import(moduleUrl);
|
|
27
|
+
const handler = extension[request.capability];
|
|
28
|
+
if (typeof handler !== 'function') {
|
|
29
|
+
throw new Error(`Extension does not export ${request.capability}().`);
|
|
30
|
+
}
|
|
31
|
+
const result = await handler(request.input);
|
|
32
|
+
write({ ok: true, result });
|
|
33
|
+
} catch (error) {
|
|
34
|
+
write({ ok: false, error: String(error?.message || error).slice(0, 2000) });
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import { redactSensitiveUrl } from './utils.js';
|
|
7
|
+
|
|
8
|
+
const RUNNER_PATH = fileURLToPath(new URL('./extension-runner.mjs', import.meta.url));
|
|
9
|
+
const EXTENSION_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
10
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
11
|
+
const CAPABILITIES = new Set(['contextProvider', 'messageValidator', 'providerAdapter']);
|
|
12
|
+
const MAX_MANIFEST_BYTES = 32_000;
|
|
13
|
+
const MAX_RESULT_CHARS = 128_000;
|
|
14
|
+
const SECRET_FIELD =
|
|
15
|
+
/^(?:authorization|proxy[-_]?authorization|cookie|x[-_]?api[-_]?key|api[-_]?key|access[-_]?token|refresh[-_]?token|bearer[-_]?token|token|password|passwd|secret(?:[-_]?key)?|client[-_]?secret|credential)$/i;
|
|
16
|
+
|
|
17
|
+
export const EXTENSION_HOST = Symbol.for('aicommit.extensionHost');
|
|
18
|
+
|
|
19
|
+
function object(value) {
|
|
20
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function strictKeys(value, allowed, label) {
|
|
24
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
25
|
+
if (unknown.length) throw new Error(`${label} contains unknown field(s): ${unknown.join(', ')}.`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isExtensionProviderType(value) {
|
|
29
|
+
return (
|
|
30
|
+
typeof value === 'string' &&
|
|
31
|
+
value.startsWith('extension:') &&
|
|
32
|
+
EXTENSION_ID.test(value.slice(10))
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validateExtensionsConfig(value) {
|
|
37
|
+
if (!object(value)) throw new Error('Invalid config "extensions": expected an object.');
|
|
38
|
+
strictKeys(value, new Set(['manifests', 'timeoutMs', 'maxContextChars']), 'Config "extensions"');
|
|
39
|
+
if (
|
|
40
|
+
!Array.isArray(value.manifests) ||
|
|
41
|
+
value.manifests.length > 20 ||
|
|
42
|
+
value.manifests.some(
|
|
43
|
+
(path) => typeof path !== 'string' || !isAbsolute(path) || path.includes('\0'),
|
|
44
|
+
)
|
|
45
|
+
) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'Invalid config "extensions.manifests": expected at most 20 absolute manifest paths.',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isInteger(value.timeoutMs) || value.timeoutMs < 100 || value.timeoutMs > 30_000) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'Invalid config "extensions.timeoutMs": expected an integer between 100 and 30000.',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
!Number.isInteger(value.maxContextChars) ||
|
|
57
|
+
value.maxContextChars < 0 ||
|
|
58
|
+
value.maxContextChars > 10_000
|
|
59
|
+
) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'Invalid config "extensions.maxContextChars": expected an integer between 0 and 10000.',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateManifest(value, manifestPath) {
|
|
68
|
+
if (!object(value)) throw new Error(`Extension manifest ${manifestPath} must be an object.`);
|
|
69
|
+
strictKeys(
|
|
70
|
+
value,
|
|
71
|
+
new Set(['kind', 'apiVersion', 'id', 'version', 'entry', 'capabilities', 'permissions']),
|
|
72
|
+
`Extension manifest ${manifestPath}`,
|
|
73
|
+
);
|
|
74
|
+
if (value.kind !== 'aicommit-extension') throw new Error(`${manifestPath}: invalid kind.`);
|
|
75
|
+
if (value.apiVersion !== 1) throw new Error(`${manifestPath}: apiVersion must be 1.`);
|
|
76
|
+
if (!EXTENSION_ID.test(value.id || '')) throw new Error(`${manifestPath}: invalid extension id.`);
|
|
77
|
+
if (!SEMVER.test(value.version || '')) throw new Error(`${manifestPath}: invalid version.`);
|
|
78
|
+
if (
|
|
79
|
+
typeof value.entry !== 'string' ||
|
|
80
|
+
!value.entry.endsWith('.mjs') ||
|
|
81
|
+
isAbsolute(value.entry) ||
|
|
82
|
+
value.entry.includes('\0')
|
|
83
|
+
) {
|
|
84
|
+
throw new Error(`${manifestPath}: entry must be a relative .mjs path.`);
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
!Array.isArray(value.capabilities) ||
|
|
88
|
+
value.capabilities.length === 0 ||
|
|
89
|
+
new Set(value.capabilities).size !== value.capabilities.length ||
|
|
90
|
+
value.capabilities.some((name) => !CAPABILITIES.has(name))
|
|
91
|
+
) {
|
|
92
|
+
throw new Error(`${manifestPath}: capabilities contain an unsupported or duplicate value.`);
|
|
93
|
+
}
|
|
94
|
+
if (!object(value.permissions))
|
|
95
|
+
throw new Error(`${manifestPath}: permissions must be an object.`);
|
|
96
|
+
strictKeys(value.permissions, new Set(['credentials']), `${manifestPath} permissions`);
|
|
97
|
+
if (value.permissions.credentials !== false) {
|
|
98
|
+
throw new Error(`${manifestPath}: permissions.credentials must be false in extension API v1.`);
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function loadManifest(manifestPath) {
|
|
104
|
+
const manifestStat = await lstat(manifestPath);
|
|
105
|
+
if (!manifestStat.isFile() || manifestStat.isSymbolicLink()) {
|
|
106
|
+
throw new Error(`Extension manifest must be a regular non-symlink file: ${manifestPath}`);
|
|
107
|
+
}
|
|
108
|
+
if (manifestStat.size > MAX_MANIFEST_BYTES) {
|
|
109
|
+
throw new Error(`Extension manifest exceeds ${MAX_MANIFEST_BYTES} bytes: ${manifestPath}`);
|
|
110
|
+
}
|
|
111
|
+
const canonicalManifest = await realpath(manifestPath);
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(await readFile(canonicalManifest, 'utf8'));
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw new Error(`Failed to parse extension manifest ${manifestPath}: ${error.message}`);
|
|
117
|
+
}
|
|
118
|
+
const manifest = validateManifest(parsed, manifestPath);
|
|
119
|
+
const root = dirname(canonicalManifest);
|
|
120
|
+
const candidate = resolve(root, manifest.entry);
|
|
121
|
+
const prefix = `${root}${process.platform === 'win32' ? '\\' : '/'}`;
|
|
122
|
+
if (!candidate.startsWith(prefix))
|
|
123
|
+
throw new Error(`${manifestPath}: entry escapes its directory.`);
|
|
124
|
+
const entryStat = await lstat(candidate);
|
|
125
|
+
if (!entryStat.isFile() || entryStat.isSymbolicLink()) {
|
|
126
|
+
throw new Error(`${manifestPath}: entry must be a regular non-symlink file.`);
|
|
127
|
+
}
|
|
128
|
+
const entry = await realpath(candidate);
|
|
129
|
+
if (!entry.startsWith(prefix)) throw new Error(`${manifestPath}: entry escapes its directory.`);
|
|
130
|
+
return Object.freeze({ ...manifest, manifestPath: canonicalManifest, entry });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function permissionArgs(version = process.versions.node) {
|
|
134
|
+
const major = Number(version.split('.')[0]);
|
|
135
|
+
if (major < 20) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
'Executable extensions require Node.js 20 or newer for credential-deny isolation. Core aicommit remains supported on Node.js 18 when extensions are disabled.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return [major >= 23 ? '--permission' : '--experimental-permission'];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function safeEnvironment() {
|
|
144
|
+
return {
|
|
145
|
+
PATH: process.env.PATH || '',
|
|
146
|
+
LANG: process.env.LANG || 'C',
|
|
147
|
+
LC_ALL: process.env.LC_ALL || '',
|
|
148
|
+
NO_COLOR: '1',
|
|
149
|
+
NODE_NO_WARNINGS: '1',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function invokeExtension(extension, capability, input, timeoutMs) {
|
|
154
|
+
return new Promise((resolvePromise, reject) => {
|
|
155
|
+
const args = [
|
|
156
|
+
...permissionArgs(),
|
|
157
|
+
`--allow-fs-read=${RUNNER_PATH}`,
|
|
158
|
+
`--allow-fs-read=${extension.entry}`,
|
|
159
|
+
RUNNER_PATH,
|
|
160
|
+
];
|
|
161
|
+
const child = spawn(process.execPath, args, {
|
|
162
|
+
cwd: dirname(extension.entry),
|
|
163
|
+
env: safeEnvironment(),
|
|
164
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
165
|
+
windowsHide: true,
|
|
166
|
+
});
|
|
167
|
+
let stdout = '';
|
|
168
|
+
let stderr = '';
|
|
169
|
+
let settled = false;
|
|
170
|
+
const finish = (fn, value) => {
|
|
171
|
+
if (settled) return;
|
|
172
|
+
settled = true;
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
fn(value);
|
|
175
|
+
};
|
|
176
|
+
const timer = setTimeout(() => {
|
|
177
|
+
child.kill('SIGKILL');
|
|
178
|
+
finish(reject, new Error(`Extension ${extension.id} timed out after ${timeoutMs}ms.`));
|
|
179
|
+
}, timeoutMs);
|
|
180
|
+
child.stdout.setEncoding('utf8');
|
|
181
|
+
child.stderr.setEncoding('utf8');
|
|
182
|
+
child.stdout.on('data', (chunk) => {
|
|
183
|
+
stdout += chunk;
|
|
184
|
+
if (stdout.length > MAX_RESULT_CHARS) {
|
|
185
|
+
child.kill('SIGKILL');
|
|
186
|
+
finish(reject, new Error(`Extension ${extension.id} output exceeded the safety limit.`));
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
child.stderr.on('data', (chunk) => {
|
|
190
|
+
stderr += chunk;
|
|
191
|
+
if (stderr.length > MAX_RESULT_CHARS) {
|
|
192
|
+
child.kill('SIGKILL');
|
|
193
|
+
finish(
|
|
194
|
+
reject,
|
|
195
|
+
new Error(`Extension ${extension.id} diagnostics exceeded the safety limit.`),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
child.on('error', (error) => finish(reject, error));
|
|
200
|
+
child.on('close', () => {
|
|
201
|
+
if (settled) return;
|
|
202
|
+
try {
|
|
203
|
+
const response = JSON.parse(stdout.trim());
|
|
204
|
+
if (!response?.ok) {
|
|
205
|
+
throw new Error(response?.error || stderr.trim() || 'extension process failed');
|
|
206
|
+
}
|
|
207
|
+
finish(resolvePromise, response.result);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
finish(
|
|
210
|
+
reject,
|
|
211
|
+
new Error(
|
|
212
|
+
`Extension ${extension.id} failed: ${error.message}${stderr ? ` (${stderr.trim().slice(0, 500)})` : ''}`,
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
child.stdin.end(JSON.stringify({ entry: extension.entry, capability, input }));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function containsCredentialField(value, depth = 0) {
|
|
222
|
+
if (depth > 12) return true;
|
|
223
|
+
if (!value || typeof value !== 'object') return false;
|
|
224
|
+
if (Array.isArray(value)) return value.some((item) => containsCredentialField(item, depth + 1));
|
|
225
|
+
return Object.entries(value).some(
|
|
226
|
+
([key, item]) => SECRET_FIELD.test(key) || containsCredentialField(item, depth + 1),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function safeAdapterConfig(config) {
|
|
231
|
+
if (containsCredentialField(config.extraBody) || containsCredentialField(config.reasoning)) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
'Extension provider configuration contains a credential-like field that cannot cross the extension boundary.',
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
apiUrl: redactSensitiveUrl(config.apiUrl),
|
|
238
|
+
modelId: config.modelId,
|
|
239
|
+
extraBody: config.extraBody,
|
|
240
|
+
reasoning: config.reasoning,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function createExtensionHost(settings) {
|
|
245
|
+
validateExtensionsConfig(settings);
|
|
246
|
+
const loaded = [];
|
|
247
|
+
const ids = new Set();
|
|
248
|
+
for (const path of settings.manifests) {
|
|
249
|
+
const extension = await loadManifest(path);
|
|
250
|
+
if (ids.has(extension.id)) throw new Error(`Duplicate extension id: ${extension.id}.`);
|
|
251
|
+
ids.add(extension.id);
|
|
252
|
+
loaded.push(extension);
|
|
253
|
+
}
|
|
254
|
+
if (loaded.length) permissionArgs();
|
|
255
|
+
const byCapability = (name) => loaded.filter((item) => item.capabilities.includes(name));
|
|
256
|
+
const findProvider = (providerType) => {
|
|
257
|
+
const id = providerType.slice(10);
|
|
258
|
+
const extension = loaded.find(
|
|
259
|
+
(item) => item.id === id && item.capabilities.includes('providerAdapter'),
|
|
260
|
+
);
|
|
261
|
+
if (!extension)
|
|
262
|
+
throw new Error(`Provider extension "${id}" is not installed or lacks providerAdapter.`);
|
|
263
|
+
return extension;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return Object.freeze({
|
|
267
|
+
extensions: loaded.map(({ id, version, capabilities }) => ({ id, version, capabilities })),
|
|
268
|
+
async collectContext({ files = [], branch = '' }) {
|
|
269
|
+
const parts = [];
|
|
270
|
+
const warnings = [];
|
|
271
|
+
for (const extension of byCapability('contextProvider')) {
|
|
272
|
+
try {
|
|
273
|
+
const result = await invokeExtension(
|
|
274
|
+
extension,
|
|
275
|
+
'contextProvider',
|
|
276
|
+
{
|
|
277
|
+
repository: basename(process.cwd()),
|
|
278
|
+
branch: String(branch).slice(0, 300),
|
|
279
|
+
files: files.slice(0, 500).map(({ status, path }) => ({
|
|
280
|
+
status: String(status).slice(0, 10),
|
|
281
|
+
path: String(path).slice(0, 500),
|
|
282
|
+
})),
|
|
283
|
+
},
|
|
284
|
+
settings.timeoutMs,
|
|
285
|
+
);
|
|
286
|
+
if (!object(result) || typeof result.text !== 'string') {
|
|
287
|
+
throw new Error('contextProvider must return { text, warnings? }.');
|
|
288
|
+
}
|
|
289
|
+
const remaining = settings.maxContextChars - parts.join('\n').length;
|
|
290
|
+
if (remaining > 0 && result.text.trim()) {
|
|
291
|
+
parts.push(`[extension:${extension.id}]\n${result.text.trim().slice(0, remaining)}`);
|
|
292
|
+
}
|
|
293
|
+
if (Array.isArray(result.warnings)) {
|
|
294
|
+
warnings.push(
|
|
295
|
+
...result.warnings
|
|
296
|
+
.filter((item) => typeof item === 'string')
|
|
297
|
+
.slice(0, 10)
|
|
298
|
+
.map((item) => `[extension:${extension.id}] ${item.slice(0, 500)}`),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
} catch (error) {
|
|
302
|
+
warnings.push(`[extension:${extension.id}] context unavailable: ${error.message}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return { text: parts.join('\n\n').slice(0, settings.maxContextChars), warnings };
|
|
306
|
+
},
|
|
307
|
+
async validateMessage(message, policy) {
|
|
308
|
+
const issues = [];
|
|
309
|
+
for (const extension of byCapability('messageValidator')) {
|
|
310
|
+
const result = await invokeExtension(
|
|
311
|
+
extension,
|
|
312
|
+
'messageValidator',
|
|
313
|
+
{ message: String(message).slice(0, 20_000), policy },
|
|
314
|
+
settings.timeoutMs,
|
|
315
|
+
);
|
|
316
|
+
if (!object(result) || !Array.isArray(result.issues)) {
|
|
317
|
+
throw new Error(`Extension ${extension.id} messageValidator must return { issues: [] }.`);
|
|
318
|
+
}
|
|
319
|
+
for (const issue of result.issues.slice(0, 50)) {
|
|
320
|
+
if (
|
|
321
|
+
!object(issue) ||
|
|
322
|
+
!['error', 'warning'].includes(issue.severity) ||
|
|
323
|
+
typeof issue.code !== 'string' ||
|
|
324
|
+
!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(issue.code) ||
|
|
325
|
+
typeof issue.message !== 'string' ||
|
|
326
|
+
!issue.message.trim()
|
|
327
|
+
) {
|
|
328
|
+
throw new Error(`Extension ${extension.id} returned an invalid validation issue.`);
|
|
329
|
+
}
|
|
330
|
+
issues.push({
|
|
331
|
+
severity: issue.severity,
|
|
332
|
+
code: `extension:${extension.id}:${issue.code}`,
|
|
333
|
+
message: `[extension:${extension.id}] ${issue.message.trim().slice(0, 500)}`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return issues;
|
|
338
|
+
},
|
|
339
|
+
providerAdapter(config) {
|
|
340
|
+
const extension = findProvider(config.providerType);
|
|
341
|
+
const capabilities = Object.freeze({
|
|
342
|
+
streaming: false,
|
|
343
|
+
reasoning: 'configurable',
|
|
344
|
+
tokenBudget: 'extension',
|
|
345
|
+
usage: true,
|
|
346
|
+
finishReason: true,
|
|
347
|
+
});
|
|
348
|
+
const call = (operation, input) =>
|
|
349
|
+
invokeExtension(
|
|
350
|
+
extension,
|
|
351
|
+
'providerAdapter',
|
|
352
|
+
{ operation, config: safeAdapterConfig(config), ...input },
|
|
353
|
+
settings.timeoutMs,
|
|
354
|
+
);
|
|
355
|
+
return Object.freeze({
|
|
356
|
+
id: `extension:${extension.id}`,
|
|
357
|
+
capabilities,
|
|
358
|
+
headers: {},
|
|
359
|
+
async buildRequest(request) {
|
|
360
|
+
if (
|
|
361
|
+
containsCredentialField(request?.extraBody) ||
|
|
362
|
+
containsCredentialField(request?.reasoning)
|
|
363
|
+
) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
'Extension provider request contains a credential-like field that cannot cross the extension boundary.',
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
const payload = await call('buildRequest', { request });
|
|
369
|
+
if (!object(payload))
|
|
370
|
+
throw new Error('providerAdapter buildRequest must return an object.');
|
|
371
|
+
if (containsCredentialField(payload)) {
|
|
372
|
+
throw new Error('providerAdapter returned a credential-like request field.');
|
|
373
|
+
}
|
|
374
|
+
return payload;
|
|
375
|
+
},
|
|
376
|
+
async normalizeResponse(data) {
|
|
377
|
+
const result = await call('normalizeResponse', { response: data });
|
|
378
|
+
if (!object(result) || typeof result.content !== 'string') {
|
|
379
|
+
throw new Error(
|
|
380
|
+
'providerAdapter normalizeResponse must return an object with content.',
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
provider: `extension:${extension.id}`,
|
|
385
|
+
model: typeof result.model === 'string' ? result.model : null,
|
|
386
|
+
content: result.content,
|
|
387
|
+
reasoning: typeof result.reasoning === 'string' ? result.reasoning : null,
|
|
388
|
+
usage: object(result.usage) ? result.usage : null,
|
|
389
|
+
finishReason: typeof result.finishReason === 'string' ? result.finishReason : null,
|
|
390
|
+
raw: data,
|
|
391
|
+
};
|
|
392
|
+
},
|
|
393
|
+
async reasoningForFollowUp(reasoning) {
|
|
394
|
+
if (containsCredentialField(reasoning)) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
'Extension provider reasoning config contains a credential-like field that cannot cross the extension boundary.',
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
const result = await call('reasoningForFollowUp', { reasoning });
|
|
400
|
+
if (!object(result))
|
|
401
|
+
throw new Error('providerAdapter reasoningForFollowUp must return an object.');
|
|
402
|
+
return result;
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function configureExtensionHost(config) {
|
|
410
|
+
const host = await createExtensionHost(config.extensions);
|
|
411
|
+
Object.defineProperty(config, EXTENSION_HOST, { value: host, enumerable: true });
|
|
412
|
+
return host;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function extensionHostFor(config) {
|
|
416
|
+
return config?.[EXTENSION_HOST] || null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function resolveProviderAdapter(config, builtInResolver) {
|
|
420
|
+
if (isExtensionProviderType(config.providerType)) {
|
|
421
|
+
const host = extensionHostFor(config);
|
|
422
|
+
if (!host) throw new Error('Provider extension runtime is not configured.');
|
|
423
|
+
return host.providerAdapter(config);
|
|
424
|
+
}
|
|
425
|
+
return builtInResolver(config);
|
|
426
|
+
}
|