aptly 0.1.0 → 0.1.2
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/README.en.md +23 -5
- package/README.ja.md +23 -5
- package/README.md +23 -5
- package/README.zh-CN.md +23 -5
- package/dist/bin/aptly.js +85 -7
- package/dist/src/cli-input.d.ts +5 -0
- package/dist/src/cli-input.js +49 -0
- package/dist/src/core.d.ts +1 -0
- package/dist/src/core.js +24 -9
- package/dist/src/credential-setup.d.ts +18 -0
- package/dist/src/credential-setup.js +89 -0
- package/dist/src/files.js +10 -3
- package/dist/src/ledger-lock.d.ts +8 -0
- package/dist/src/ledger-lock.js +153 -0
- package/dist/src/provider.js +13 -4
- package/dist/src/runtime.d.ts +5 -1
- package/dist/src/runtime.js +19 -3
- package/dist/src/secret-store.d.ts +27 -0
- package/dist/src/secret-store.js +263 -0
- package/dist/src/server.js +2 -1
- package/dist/src/setup.d.ts +14 -0
- package/dist/src/setup.js +297 -29
- package/dist/src/types.d.ts +4 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/windows-powershell.d.ts +2 -0
- package/dist/src/windows-powershell.js +11 -0
- package/dist/src/windows-private.d.ts +11 -0
- package/dist/src/windows-private.js +98 -0
- package/docs/guide/en.md +148 -15
- package/docs/guide/ja.md +148 -15
- package/docs/guide/ko.md +148 -15
- package/docs/guide/zh-CN.md +148 -15
- package/package.json +3 -2
package/dist/src/server.js
CHANGED
|
@@ -13,7 +13,7 @@ const constraintsSchema = z.object({ model_pinned: z.boolean().optional(), effor
|
|
|
13
13
|
const hookSchema = { client: z.enum(['codex', 'claude']), event: z.record(z.string(), z.unknown()) };
|
|
14
14
|
const content = (result) => ({ content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result });
|
|
15
15
|
export function createServer({ env = process.env } = {}) {
|
|
16
|
-
const { router, mode, modelInfo, disabledClients } = runtime(env);
|
|
16
|
+
const { router, mode, modelInfo, credentialStatus, disabledClients } = runtime(env);
|
|
17
17
|
let lastAutomaticHook = null;
|
|
18
18
|
const server = new McpServer({ name: 'aptly', version: VERSION });
|
|
19
19
|
const annotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: mode === 'decisions' };
|
|
@@ -28,6 +28,7 @@ export function createServer({ env = process.env } = {}) {
|
|
|
28
28
|
precedence: 'per_call_effective_host_evidence_required', automatic_settings_discovery: false,
|
|
29
29
|
recommendation: { application: 'advisory_only', host_evidence_required: false, constraints: 'caller_declared_pins' },
|
|
30
30
|
model_information: { status: modelInfo.status, refresh: 'explicit_only', account_access_verified: false }, disabled_clients: disabledClients,
|
|
31
|
+
credential_status: credentialStatus,
|
|
31
32
|
automatic_hook: { status: loadAutomaticHookPolicy(env.APTLY_HOOK_POLICY_PATH).reason, mode: loadAutomaticHookPolicy(env.APTLY_HOOK_POLICY_PATH).policy?.mode ?? 'disabled', host_settings_verified: false, last_result: lastAutomaticHook },
|
|
32
33
|
}));
|
|
33
34
|
server.registerTool('recommend_agent', {
|
package/dist/src/setup.d.ts
CHANGED
|
@@ -12,11 +12,25 @@ export interface SetupClientResult {
|
|
|
12
12
|
status: 'configured' | 'unchanged' | 'removed' | 'skipped' | 'error';
|
|
13
13
|
codes: string[];
|
|
14
14
|
requires_hook_review?: boolean;
|
|
15
|
+
recovery?: string[];
|
|
15
16
|
}
|
|
16
17
|
export interface SetupResult {
|
|
17
18
|
status: 'complete' | 'partial' | 'skipped';
|
|
18
19
|
clients: SetupClientResult[];
|
|
19
20
|
}
|
|
21
|
+
export declare const configurableEnvironmentKeys: readonly string[];
|
|
20
22
|
export declare function setupHosts(options?: SetupOptions): SetupResult;
|
|
21
23
|
export declare function removeSetup(options?: SetupOptions): SetupResult;
|
|
24
|
+
export declare function setDefaults(options?: SetupOptions): SetupResult;
|
|
25
|
+
export declare function setEnvironment(key: string, value: string, options?: SetupOptions): SetupResult;
|
|
26
|
+
/** Store only the nonsecret source reference; credential values never enter setup. */
|
|
27
|
+
export declare function setCredentialReference(options?: SetupOptions): SetupResult;
|
|
28
|
+
/** Snapshot-only plan. No file writes, credential access, paths or values in diagnostics. */
|
|
29
|
+
export declare function prepareCredentialReference(options: SetupOptions, client: Client): {
|
|
30
|
+
result: SetupClientResult;
|
|
31
|
+
check: () => SetupClientResult;
|
|
32
|
+
commit: () => SetupClientResult;
|
|
33
|
+
};
|
|
34
|
+
/** Only an explicitly selected, receipt-matching standard registration is eligible. */
|
|
35
|
+
export declare function migrateLauncher(options: SetupOptions, dryRun?: boolean): SetupResult;
|
|
22
36
|
export {};
|
package/dist/src/setup.js
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
import { lstatSync, readFileSync, mkdirSync, chmodSync, openSync, writeFileSync, closeSync, renameSync, unlinkSync, rmdirSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import { isDeepStrictEqual } from 'node:util';
|
|
7
7
|
import { parse } from 'smol-toml';
|
|
8
|
+
import { resolveCatalog } from './catalog.js';
|
|
9
|
+
import { assertPrivateStatePath } from './files.js';
|
|
10
|
+
import { assertWindowsPrivatePath, protectNewWindowsPrivateDirectory, copyWindowsFilePermissions } from './windows-private.js';
|
|
8
11
|
const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
9
12
|
const stringify = (value) => JSON.stringify(value, null, 2) + '\n';
|
|
10
13
|
const fail = (code) => { throw new Error(code); };
|
|
14
|
+
function privatePermissions(path, code) {
|
|
15
|
+
if (process.platform === 'win32') {
|
|
16
|
+
try {
|
|
17
|
+
assertWindowsPrivatePath(path);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
fail(code);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
else if ((lstatSync(path).mode & 0o077) !== 0)
|
|
24
|
+
fail(code);
|
|
25
|
+
}
|
|
11
26
|
const checkedDirectory = (path) => { const s = lstatSync(path); if (!s.isDirectory() || s.isSymbolicLink())
|
|
12
27
|
fail('unsafe_directory'); };
|
|
13
28
|
function noSymlinks(path) {
|
|
@@ -140,6 +155,85 @@ function removeTomlEnv(text, additions) {
|
|
|
140
155
|
fail('managed_entry_changed');
|
|
141
156
|
return text;
|
|
142
157
|
}
|
|
158
|
+
export const configurableEnvironmentKeys = Object.freeze(['ROUTER_PROVIDER', 'APTLY_CLAUDE_MODELS', 'APTLY_CODEX_MODELS', 'CLAUDE_MODELS', 'CODEX_MODELS', 'APTLY_MODEL_INFO_PATH']);
|
|
159
|
+
const defaultEnvironment = { ROUTER_PROVIDER: 'decisions', APTLY_CLAUDE_MODELS: '["haiku","sonnet","opus"]', APTLY_CODEX_MODELS: '["gpt-6-luna","gpt-6.1-terra","gpt-6.1-sol"]' };
|
|
160
|
+
function validateEnvironmentValue(key, value) {
|
|
161
|
+
if (!configurableEnvironmentKeys.includes(key))
|
|
162
|
+
fail(/KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i.test(key) ? 'environment_secret_key_unsupported' : 'environment_key_unsupported');
|
|
163
|
+
if (typeof value !== 'string' || !value || Buffer.byteLength(value) > 8192 || /[\x00-\x1f\x7f]/.test(value))
|
|
164
|
+
fail('environment_invalid_value');
|
|
165
|
+
if (/\bsk-[A-Za-z0-9_-]{8,}|\bBearer\s+\S+|https?:\/\/[^/\s]+:[^/\s]+@/i.test(value))
|
|
166
|
+
fail('environment_sensitive_value');
|
|
167
|
+
if (key === 'ROUTER_PROVIDER' && !['fixture', 'decisions'].includes(value))
|
|
168
|
+
fail('environment_invalid_value');
|
|
169
|
+
if (key === 'APTLY_MODEL_INFO_PATH') {
|
|
170
|
+
try {
|
|
171
|
+
assertPrivateStatePath(value);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
fail('environment_invalid_value');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function editEnvironment(current, edit, client) {
|
|
179
|
+
const updates = {};
|
|
180
|
+
if ('defaults' in edit) {
|
|
181
|
+
for (const [key, value] of Object.entries(defaultEnvironment)) {
|
|
182
|
+
const alias = key === 'APTLY_CLAUDE_MODELS' ? 'CLAUDE_MODELS' : key === 'APTLY_CODEX_MODELS' ? 'CODEX_MODELS' : null;
|
|
183
|
+
if (current[key] === undefined && (!alias || current[alias] === undefined))
|
|
184
|
+
updates[key] = value;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else if ('credential' in edit) {
|
|
188
|
+
updates.APTLY_SECRET_BACKEND = 'os';
|
|
189
|
+
updates.APTLY_SECRET_CLIENT = client;
|
|
190
|
+
}
|
|
191
|
+
else if (!('migrate' in edit)) {
|
|
192
|
+
validateEnvironmentValue(edit.key, edit.value);
|
|
193
|
+
updates[edit.key] = edit.value;
|
|
194
|
+
}
|
|
195
|
+
const effective = { ...current, ...updates };
|
|
196
|
+
if (effective.ROUTER_PROVIDER !== undefined && !['fixture', 'decisions'].includes(effective.ROUTER_PROVIDER))
|
|
197
|
+
fail('environment_invalid_value');
|
|
198
|
+
try {
|
|
199
|
+
resolveCatalog(effective);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
fail('environment_invalid_value');
|
|
203
|
+
}
|
|
204
|
+
return Object.fromEntries(Object.entries(updates).filter(([key, value]) => current[key] !== value));
|
|
205
|
+
}
|
|
206
|
+
/** Change only selected single-line scalar env assignments; refuse ambiguous TOML layouts. */
|
|
207
|
+
function updateTomlEnv(text, updates) {
|
|
208
|
+
const parsed = toml(text), existing = parsed.mcp_servers?.aptly?.env ?? {};
|
|
209
|
+
const expected = structuredClone(parsed);
|
|
210
|
+
Object.assign(map(map(map(expected, 'mcp_servers'), 'aptly'), 'env'), updates);
|
|
211
|
+
const headers = [...text.matchAll(/^\s*\[([^\n]+)\][ \t]*(?:#[^\n]*)?\r?$/gm)];
|
|
212
|
+
const header = headers.find(h => { try {
|
|
213
|
+
return toml(h[0] + '\naptly_probe = true\n').mcp_servers?.aptly?.env?.aptly_probe === true;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return false;
|
|
217
|
+
} });
|
|
218
|
+
const present = Object.entries(updates).filter(([key]) => existing[key] !== undefined);
|
|
219
|
+
if (present.length) {
|
|
220
|
+
if (!header)
|
|
221
|
+
fail('unsupported_toml_layout');
|
|
222
|
+
const end = headers.find(h => h.index > header.index)?.index ?? text.length;
|
|
223
|
+
let body = text.slice(header.index + header[0].length, end);
|
|
224
|
+
for (const [key, value] of present) {
|
|
225
|
+
const line = new RegExp('^[ \t]*' + key + '[ \t]*=[^\n]*(?:\n|$)', 'gm');
|
|
226
|
+
if ([...body.matchAll(line)].length !== 1)
|
|
227
|
+
fail('unsupported_toml_layout');
|
|
228
|
+
body = body.replace(line, () => key + ' = ' + q(value) + '\n');
|
|
229
|
+
}
|
|
230
|
+
text = text.slice(0, header.index + header[0].length) + body + text.slice(end);
|
|
231
|
+
}
|
|
232
|
+
text = addTomlEnv(text, Object.fromEntries(Object.entries(updates).filter(([key]) => existing[key] === undefined)));
|
|
233
|
+
if (!isDeepStrictEqual(toml(text), expected))
|
|
234
|
+
fail('unsupported_toml_layout');
|
|
235
|
+
return text;
|
|
236
|
+
}
|
|
143
237
|
function resolved(options, client) {
|
|
144
238
|
const home = resolve(options.home ?? homedir()), env = options.env ?? process.env;
|
|
145
239
|
const override = client === 'codex' ? env.CODEX_HOME : env.CLAUDE_CONFIG_DIR;
|
|
@@ -154,16 +248,34 @@ function ownership(value, client) {
|
|
|
154
248
|
fail('invalid_ownership');
|
|
155
249
|
return value;
|
|
156
250
|
}
|
|
157
|
-
|
|
251
|
+
/** Compatibility restriction only: this does not authenticate arbitrary launcher code. */
|
|
252
|
+
function requireCredentialRuntime(entry, root) {
|
|
253
|
+
const allowed = [join(root, 'dist', 'src', 'server.js'), join(root, 'src', 'server.ts')];
|
|
254
|
+
const incompatible = (reason) => { throw Object.assign(new Error('credential_launcher_unverified'), { reason }); };
|
|
255
|
+
if (entry.command !== process.execPath)
|
|
256
|
+
incompatible('credential_node_mismatch');
|
|
257
|
+
if (!Array.isArray(entry.args) || entry.args.length !== 1 || !allowed.includes(entry.args[0]))
|
|
258
|
+
incompatible('credential_server_mismatch');
|
|
259
|
+
try {
|
|
260
|
+
const extension = entry.args[0].endsWith('.ts') ? 'ts' : 'js';
|
|
261
|
+
const server = read(entry.args[0]).text;
|
|
262
|
+
const runtime = read(join(dirname(entry.args[0]), 'runtime.' + extension)).text;
|
|
263
|
+
if (!server?.includes('./runtime.' + extension) || !runtime?.includes('APTLY_SECRET_BACKEND') || !runtime.includes('APTLY_SECRET_CLIENT'))
|
|
264
|
+
fail('credential_launcher_unverified');
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
incompatible('credential_runtime_unsupported');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function prepare(options, client, removing, edit) {
|
|
158
271
|
const env = options.env ?? process.env, paths = resolved(options, client), { dir, mcpPath, hooksPath, privateDir } = paths;
|
|
159
|
-
const changes = [], dirs = [];
|
|
272
|
+
const changes = [], observations = [], dirs = [];
|
|
160
273
|
noSymlinks(dir);
|
|
161
274
|
noSymlinks(privateDir);
|
|
162
275
|
for (const path of [privateDir, join(privateDir, 'state')]) {
|
|
163
276
|
try {
|
|
164
277
|
checkedDirectory(path);
|
|
165
|
-
|
|
166
|
-
fail('private_directory_permissions');
|
|
278
|
+
privatePermissions(path, 'private_directory_permissions');
|
|
167
279
|
}
|
|
168
280
|
catch (e) {
|
|
169
281
|
if (e.code !== 'ENOENT')
|
|
@@ -181,16 +293,17 @@ function prepare(options, client, removing) {
|
|
|
181
293
|
} })();
|
|
182
294
|
const result = { client, status: 'unchanged', codes: [] };
|
|
183
295
|
if (!hasDir && (options.automatic || removing))
|
|
184
|
-
return { changes, dirs, result: { client, status: 'skipped', codes: ['host_not_configured'] } };
|
|
296
|
+
return { changes, observations, dirs, result: { client, status: 'skipped', codes: ['host_not_configured'] } };
|
|
185
297
|
const policyPath = join(privateDir, 'auto-policy.json'), manifestPath = join(privateDir, 'setup-ownership.json'), stateDir = join(privateDir, 'state');
|
|
186
298
|
const snap = (path) => { const source = read(path); return { path, before: source.text, after: source.text, mode: source.mode }; };
|
|
187
299
|
const mcp = snap(mcpPath), hooks = snap(hooksPath), policy = snap(policyPath), manifest = snap(manifestPath);
|
|
188
300
|
for (const privateFile of [policy, manifest])
|
|
189
|
-
if (privateFile.before !== null
|
|
190
|
-
|
|
301
|
+
if (privateFile.before !== null)
|
|
302
|
+
privatePermissions(privateFile.path, 'private_file_permissions');
|
|
303
|
+
observations.push(mcp, hooks, policy, manifest);
|
|
191
304
|
const oldOwner = manifest.before === null ? undefined : ownership(json(manifest.before), client);
|
|
192
305
|
if (removing && !oldOwner)
|
|
193
|
-
return { changes, dirs, result: { client, status: 'skipped', codes: ['no_owned_setup'] } };
|
|
306
|
+
return { changes, observations, dirs, result: { client, status: 'skipped', codes: ['no_owned_setup'] } };
|
|
194
307
|
const hookValue = hook(client), hooksObject = json(hooks.before), entries = hooksArray(hooksObject);
|
|
195
308
|
const config = client === 'codex' ? toml(mcp.before ?? '') : json(mcp.before), servers = map(config, client === 'codex' ? 'mcp_servers' : 'mcpServers');
|
|
196
309
|
if (removing) {
|
|
@@ -245,19 +358,24 @@ function prepare(options, client, removing) {
|
|
|
245
358
|
const root = resolve(options.packageRoot ?? join(dirname(fileURLToPath(import.meta.url)), '..', ...(import.meta.url.includes('/dist/src/') ? ['..'] : [])));
|
|
246
359
|
const additions = { APTLY_HOOK_POLICY_PATH: policyPath, ROUTER_LEDGER_PATH: join(stateDir, 'ledger.json') };
|
|
247
360
|
let created = false, tomlBlock;
|
|
361
|
+
if (edit && 'migrate' in edit) {
|
|
362
|
+
if (!oldOwner?.mcp_created || !object(servers.aptly) || !isDeepStrictEqual(servers.aptly, oldOwner.mcp))
|
|
363
|
+
fail('launcher_migration_not_owned');
|
|
364
|
+
const entry = servers.aptly;
|
|
365
|
+
if (typeof entry.command !== 'string' || !isAbsolute(entry.command) || !['node', 'node.exe'].includes(basename(entry.command).toLowerCase()) || !Array.isArray(entry.args) || entry.args.length !== 1 || typeof entry.args[0] !== 'string' || !isAbsolute(entry.args[0]) || !/(?:^|[\\/])(?:dist[\\/]src[\\/]server\.js|src[\\/]server\.ts)$/.test(entry.args[0]))
|
|
366
|
+
fail('launcher_migration_not_standard');
|
|
367
|
+
if (!entries.some(entry => isDeepStrictEqual(entry, oldOwner.hook)) || !isDeepStrictEqual(json(policy.before), oldOwner.policy))
|
|
368
|
+
fail('managed_entry_changed');
|
|
369
|
+
}
|
|
248
370
|
if (servers.aptly === undefined) {
|
|
249
371
|
created = true;
|
|
250
|
-
servers.aptly = { ...(client === 'claude' ? { type: 'stdio' } : {}), command: process.execPath, args: [join(root, 'dist', 'src', 'server.js')], ...(client === 'codex' ? { env_vars: ['OPENAI_API_KEY'] } : {}), env: { ROUTER_PROVIDER: env.ROUTER_PROVIDER === 'fixture' ? 'fixture' : 'decisions', ...additions } };
|
|
251
|
-
if (client === 'codex') {
|
|
252
|
-
tomlBlock = newTomlBlock(servers.aptly);
|
|
253
|
-
mcp.after = (mcp.before ?? '') + ((mcp.before ?? '').endsWith('\n') || !mcp.before ? '\n' : '\n\n') + tomlBlock;
|
|
254
|
-
}
|
|
255
|
-
else
|
|
256
|
-
mcp.after = stringify(config);
|
|
372
|
+
servers.aptly = { ...(client === 'claude' ? { type: 'stdio' } : {}), command: process.execPath, args: [join(root, 'dist', 'src', 'server.js')], ...(client === 'codex' ? { env_vars: ['OPENAI_API_KEY'] } : {}), env: { ROUTER_PROVIDER: edit && 'defaults' in edit ? 'decisions' : env.ROUTER_PROVIDER === 'fixture' ? 'fixture' : 'decisions', ...additions } };
|
|
257
373
|
}
|
|
258
374
|
else {
|
|
259
375
|
if (!object(servers.aptly))
|
|
260
376
|
fail('mcp_entry_conflict');
|
|
377
|
+
if (edit && oldOwner?.mcp_created && !isDeepStrictEqual(servers.aptly, oldOwner.mcp))
|
|
378
|
+
fail('managed_mcp_changed');
|
|
261
379
|
const currentEnv = map(servers.aptly, 'env');
|
|
262
380
|
if (currentEnv.APTLY_HOOK_POLICY_PATH !== undefined && currentEnv.APTLY_HOOK_POLICY_PATH !== policyPath)
|
|
263
381
|
fail('mcp_environment_conflict');
|
|
@@ -265,13 +383,33 @@ function prepare(options, client, removing) {
|
|
|
265
383
|
Object.assign(currentEnv, missing);
|
|
266
384
|
mcp.after = client === 'codex' ? addTomlEnv(mcp.before ?? '', missing) : stringify(config);
|
|
267
385
|
}
|
|
386
|
+
if (edit && 'migrate' in edit) {
|
|
387
|
+
servers.aptly.command = process.execPath;
|
|
388
|
+
servers.aptly.args = [join(root, 'dist', 'src', 'server.js')];
|
|
389
|
+
}
|
|
390
|
+
if (edit && ('credential' in edit || 'migrate' in edit))
|
|
391
|
+
requireCredentialRuntime(servers.aptly, root);
|
|
392
|
+
const currentEnv = map(servers.aptly, 'env');
|
|
393
|
+
const updates = edit ? editEnvironment(currentEnv, edit, client) : {};
|
|
394
|
+
Object.assign(currentEnv, updates);
|
|
395
|
+
if (created) {
|
|
396
|
+
if (client === 'codex') {
|
|
397
|
+
tomlBlock = newTomlBlock(servers.aptly);
|
|
398
|
+
mcp.after = (mcp.before ?? '') + ((mcp.before ?? '').endsWith('\n') || !mcp.before ? '\n' : '\n\n') + tomlBlock;
|
|
399
|
+
}
|
|
400
|
+
else
|
|
401
|
+
mcp.after = stringify(config);
|
|
402
|
+
}
|
|
403
|
+
else if (edit)
|
|
404
|
+
mcp.after = client === 'codex' ? updateTomlEnv(mcp.after, updates) : stringify(config);
|
|
268
405
|
const existingRoute = entries.filter(routeHook);
|
|
269
406
|
if (existingRoute.some(entry => !isDeepStrictEqual(entry, hookValue)))
|
|
270
407
|
fail('existing_routing_hook_conflict');
|
|
271
408
|
if (existingRoute.length === 0)
|
|
272
409
|
entries.push(hookValue);
|
|
273
|
-
hooks.after = stringify(hooksObject);
|
|
274
|
-
const
|
|
410
|
+
hooks.after = edit && 'migrate' in edit ? hooks.before : stringify(hooksObject);
|
|
411
|
+
const preservedMode = edit && policy.before !== null ? json(policy.before).mode : undefined;
|
|
412
|
+
const policyValue = { schema_version: 1, kind: 'aptly-auto-hook', mode: options.mode ?? (['defaults', 'advisory'].includes(preservedMode) ? preservedMode : 'defaults'), issuer: 'operator_installation', enabled: true, state_directory: stateDir };
|
|
275
413
|
if (policy.before !== null) {
|
|
276
414
|
const p = json(policy.before);
|
|
277
415
|
if (!isDeepStrictEqual(p, policyValue))
|
|
@@ -286,6 +424,36 @@ function prepare(options, client, removing) {
|
|
|
286
424
|
if (existingRoute.length && !oldOwner)
|
|
287
425
|
fail('existing_hook_not_owned');
|
|
288
426
|
const owner = oldOwner ?? { schema_version: 1, kind: 'aptly-setup-ownership', client, hook: hookValue, policy: policyValue, policy_created: policy.before === null, mcp_created: created, ...(created ? { mcp: servers.aptly } : {}), env_added: envAdded, ...(tomlBlock ? { toml_block: tomlBlock } : {}) };
|
|
427
|
+
if (edit && oldOwner) {
|
|
428
|
+
if (oldOwner.mcp_created) {
|
|
429
|
+
owner.mcp = structuredClone(servers.aptly);
|
|
430
|
+
if (client === 'codex') {
|
|
431
|
+
const oldBlock = oldOwner.toml_block;
|
|
432
|
+
if (!oldBlock || !(mcp.before ?? '').includes(oldBlock))
|
|
433
|
+
fail('managed_mcp_changed');
|
|
434
|
+
const nextBlock = newTomlBlock(servers.aptly);
|
|
435
|
+
const rendered = (mcp.before ?? '').replace(oldBlock, nextBlock);
|
|
436
|
+
if (!isDeepStrictEqual(toml(rendered), config))
|
|
437
|
+
fail('managed_mcp_changed');
|
|
438
|
+
mcp.after = rendered;
|
|
439
|
+
owner.toml_block = nextBlock;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
const prior = client === 'codex' ? toml(mcp.before ?? '') : json(mcp.before);
|
|
444
|
+
const priorEnv = prior[client === 'codex' ? 'mcp_servers' : 'mcpServers']?.aptly?.env ?? {};
|
|
445
|
+
for (const [key, value] of Object.entries(updates))
|
|
446
|
+
if (Object.hasOwn(owner.env_added, key) || priorEnv[key] === undefined)
|
|
447
|
+
owner.env_added[key] = value;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
else if (edit && !created) {
|
|
451
|
+
const prior = client === 'codex' ? toml(mcp.before ?? '') : json(mcp.before);
|
|
452
|
+
const priorEnv = prior[client === 'codex' ? 'mcp_servers' : 'mcpServers']?.aptly?.env ?? {};
|
|
453
|
+
for (const [key, value] of Object.entries(updates))
|
|
454
|
+
if (priorEnv[key] === undefined)
|
|
455
|
+
owner.env_added[key] = value;
|
|
456
|
+
}
|
|
289
457
|
manifest.after = stringify(owner);
|
|
290
458
|
if (client === 'codex')
|
|
291
459
|
result.requires_hook_review = true;
|
|
@@ -300,7 +468,7 @@ function prepare(options, client, removing) {
|
|
|
300
468
|
changes.push(snapshot);
|
|
301
469
|
if (!removing && changes.length)
|
|
302
470
|
result.status = 'configured';
|
|
303
|
-
return { changes, dirs, result };
|
|
471
|
+
return { changes, observations, dirs, result };
|
|
304
472
|
}
|
|
305
473
|
/** Secure sibling staging is short-lived and never a backup; original settings stay in memory. */
|
|
306
474
|
function replace(snapshot, forward) {
|
|
@@ -316,13 +484,21 @@ function replace(snapshot, forward) {
|
|
|
316
484
|
let fd;
|
|
317
485
|
try {
|
|
318
486
|
fd = openSync(stage, 'wx', 0o600);
|
|
487
|
+
if (process.platform === 'win32' && expected !== null) {
|
|
488
|
+
try {
|
|
489
|
+
copyWindowsFilePermissions(snapshot.path, stage);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
fail('private_file_permissions');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
319
495
|
writeFileSync(fd, next);
|
|
320
496
|
closeSync(fd);
|
|
321
497
|
fd = undefined;
|
|
322
498
|
if (read(snapshot.path).text !== expected)
|
|
323
499
|
fail('concurrent_configuration_change');
|
|
324
500
|
renameSync(stage, snapshot.path);
|
|
325
|
-
if (snapshot.mode !== 0o600)
|
|
501
|
+
if (process.platform !== 'win32' && snapshot.mode !== 0o600)
|
|
326
502
|
chmodSync(snapshot.path, snapshot.mode);
|
|
327
503
|
}
|
|
328
504
|
finally {
|
|
@@ -337,28 +513,31 @@ function replace(snapshot, forward) {
|
|
|
337
513
|
}
|
|
338
514
|
}
|
|
339
515
|
}
|
|
340
|
-
function execute(options, removing) {
|
|
516
|
+
function execute(options, removing, edit, captured) {
|
|
341
517
|
const clients = [...new Set(options.clients ?? ['codex', 'claude'])];
|
|
342
518
|
if (clients.some(client => client !== 'codex' && client !== 'claude'))
|
|
343
519
|
return { status: 'partial', clients: [] };
|
|
344
520
|
const env = options.env ?? process.env;
|
|
345
521
|
if (!removing && options.automatic && (env.APTLY_SKIP_SETUP === '1' || !!env.CI))
|
|
346
522
|
return { status: 'skipped', clients: clients.map(client => ({ client, status: 'skipped', codes: [env.APTLY_SKIP_SETUP === '1' ? 'setup_disabled' : 'ci_environment'] })) };
|
|
347
|
-
const prepared = clients.map(client => {
|
|
523
|
+
const prepared = captured ?? clients.map(client => {
|
|
348
524
|
try {
|
|
349
|
-
return prepare(options, client, removing);
|
|
525
|
+
return prepare(options, client, removing, edit);
|
|
350
526
|
}
|
|
351
527
|
catch (e) {
|
|
352
|
-
return { changes: [], dirs: [], result: { client, status: 'error', codes: [safeCode(e)] } };
|
|
528
|
+
return { changes: [], observations: [], dirs: [], result: { client, status: 'error', codes: [safeCode(e)] } };
|
|
353
529
|
}
|
|
354
530
|
});
|
|
355
531
|
const results = prepared.map(plan => plan.result);
|
|
532
|
+
if (edit && results.some(result => result.status === 'error'))
|
|
533
|
+
return { status: 'partial', clients: results.map(result => result.status === 'error' ? result : { ...result, status: 'skipped', codes: ['other_host_validation_failed'] }) };
|
|
356
534
|
// Each host is independent; prepare every host before committing any host.
|
|
357
535
|
for (const plan of prepared) {
|
|
358
536
|
if (!plan.changes.length)
|
|
359
537
|
continue;
|
|
360
538
|
const written = [], madeDirs = [];
|
|
361
539
|
try {
|
|
540
|
+
validateObservations(plan);
|
|
362
541
|
for (const path of plan.dirs) {
|
|
363
542
|
noSymlinks(path);
|
|
364
543
|
try {
|
|
@@ -369,16 +548,24 @@ function execute(options, removing) {
|
|
|
369
548
|
throw e;
|
|
370
549
|
mkdirSync(path, { mode: 0o700 });
|
|
371
550
|
madeDirs.push(path);
|
|
551
|
+
if (process.platform === 'win32') {
|
|
552
|
+
try {
|
|
553
|
+
protectNewWindowsPrivateDirectory(path);
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
fail('private_directory_permissions');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
372
559
|
}
|
|
373
|
-
if (path
|
|
374
|
-
|
|
375
|
-
fail('private_directory_permissions');
|
|
376
|
-
}
|
|
560
|
+
if (basename(path) === 'aptly' || (basename(path) === 'state' && basename(dirname(path)) === 'aptly'))
|
|
561
|
+
privatePermissions(path, 'private_directory_permissions');
|
|
377
562
|
}
|
|
378
563
|
for (const snapshot of plan.changes) {
|
|
564
|
+
validateObservations(plan, written);
|
|
379
565
|
replace(snapshot, true);
|
|
380
566
|
written.push(snapshot);
|
|
381
567
|
}
|
|
568
|
+
validateObservations(plan, written);
|
|
382
569
|
}
|
|
383
570
|
catch (e) {
|
|
384
571
|
let rollbackConflict = false;
|
|
@@ -402,7 +589,88 @@ function execute(options, removing) {
|
|
|
402
589
|
}
|
|
403
590
|
return { status: results.some(result => result.status === 'error') ? 'partial' : results.every(result => result.status === 'skipped') ? 'skipped' : 'complete', clients: results };
|
|
404
591
|
}
|
|
405
|
-
const codes = new Set(['unsafe_directory', 'unsafe_symlink', 'unsafe_configuration_file', 'invalid_json', 'invalid_json_object', 'configuration_shape_conflict', 'hook_shape_conflict', 'invalid_toml', 'unsupported_toml_layout', 'relative_configuration_directory', 'invalid_ownership', 'managed_hook_changed', 'managed_mcp_changed', 'managed_entry_changed', 'managed_policy_changed', 'mcp_entry_conflict', 'mcp_environment_conflict', 'existing_routing_hook_conflict', 'policy_conflict', 'existing_hook_not_owned', 'concurrent_configuration_change', 'private_directory_permissions', 'private_file_permissions']);
|
|
592
|
+
const codes = new Set(['launcher_migration_not_owned', 'launcher_migration_not_standard', 'credential_launcher_unverified', 'environment_invalid_value', 'environment_sensitive_value', 'environment_key_unsupported', 'environment_secret_key_unsupported', 'unsafe_directory', 'unsafe_symlink', 'unsafe_configuration_file', 'invalid_json', 'invalid_json_object', 'configuration_shape_conflict', 'hook_shape_conflict', 'invalid_toml', 'unsupported_toml_layout', 'relative_configuration_directory', 'invalid_ownership', 'managed_hook_changed', 'managed_mcp_changed', 'managed_entry_changed', 'managed_policy_changed', 'mcp_entry_conflict', 'mcp_environment_conflict', 'existing_routing_hook_conflict', 'policy_conflict', 'existing_hook_not_owned', 'concurrent_configuration_change', 'private_directory_permissions', 'private_file_permissions']);
|
|
406
593
|
function safeCode(error) { return error instanceof Error && codes.has(error.message) ? error.message : 'configuration_io_error'; }
|
|
407
594
|
export function setupHosts(options = {}) { return execute(options, false); }
|
|
408
595
|
export function removeSetup(options = {}) { return execute(options, true); }
|
|
596
|
+
export function setDefaults(options = {}) { return execute(options, false, { defaults: true }); }
|
|
597
|
+
export function setEnvironment(key, value, options = {}) {
|
|
598
|
+
try {
|
|
599
|
+
validateEnvironmentValue(key, value);
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
return { status: 'partial', clients: (options.clients ?? ['codex', 'claude']).map(client => ({ client, status: 'error', codes: [safeCode(error)] })) };
|
|
603
|
+
}
|
|
604
|
+
return execute(options, false, { key, value });
|
|
605
|
+
}
|
|
606
|
+
/** Store only the nonsecret source reference; credential values never enter setup. */
|
|
607
|
+
export function setCredentialReference(options = {}) { return execute(options, false, { credential: true }); }
|
|
608
|
+
/** Include unchanged files: a no-op reference update must still detect prompt-time edits. */
|
|
609
|
+
function validateObservations(plan, written = []) {
|
|
610
|
+
for (const snapshot of plan.observations) {
|
|
611
|
+
const current = read(snapshot.path);
|
|
612
|
+
const expected = written.includes(snapshot) ? snapshot.after : snapshot.before;
|
|
613
|
+
if (current.text !== expected || (process.platform !== 'win32' && expected !== null && current.mode !== snapshot.mode))
|
|
614
|
+
fail('concurrent_configuration_change');
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
function recovery(client) {
|
|
618
|
+
return [`aptly set env OPENAI_API_KEY --client ${client} --check`,
|
|
619
|
+
`aptly setup --client ${client} --migrate-launcher --dry-run`,
|
|
620
|
+
`aptly setup --client ${client} --migrate-launcher`,
|
|
621
|
+
`aptly set env OPENAI_API_KEY --client ${client}`];
|
|
622
|
+
}
|
|
623
|
+
/** Snapshot-only plan. No file writes, credential access, paths or values in diagnostics. */
|
|
624
|
+
export function prepareCredentialReference(options, client) {
|
|
625
|
+
let plan;
|
|
626
|
+
let result;
|
|
627
|
+
try {
|
|
628
|
+
plan = prepare({ ...options, clients: [client] }, client, false, { credential: true });
|
|
629
|
+
result = { client, status: 'unchanged', codes: ['credential_preflight_ok'] };
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
const reason = error?.reason;
|
|
633
|
+
const reasons = ['credential_node_mismatch', 'credential_server_mismatch', 'credential_runtime_unsupported'];
|
|
634
|
+
const migration = migrateLauncher({ ...options, clients: [client] }, true);
|
|
635
|
+
const canMigrate = migration.status === 'complete';
|
|
636
|
+
result = { client, status: 'error', codes: [safeCode(error), ...(reason && reasons.includes(reason) ? [reason] : []), ...(canMigrate ? ['launcher_migration_available'] : [])],
|
|
637
|
+
recovery: canMigrate ? recovery(client) : [`aptly set env OPENAI_API_KEY --client ${client} --check`, 'Keep custom or modified launchers unchanged. Review the host registration locally; use its secure credential environment or a separately reviewed standard registration.'] };
|
|
638
|
+
}
|
|
639
|
+
const check = () => {
|
|
640
|
+
if (!plan)
|
|
641
|
+
return result;
|
|
642
|
+
try {
|
|
643
|
+
validateObservations(plan);
|
|
644
|
+
// Recheck runtime support, symlinks, private permissions and policy before storage.
|
|
645
|
+
prepare({ ...options, clients: [client] }, client, false, { credential: true });
|
|
646
|
+
return result;
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
return { client, status: 'error', codes: [safeCode(error)], recovery: recovery(client) };
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
return { result, check, commit: () => {
|
|
653
|
+
const checked = check();
|
|
654
|
+
if (checked.status === 'error' || !plan)
|
|
655
|
+
return checked;
|
|
656
|
+
return execute({ ...options, clients: [client] }, false, { credential: true }, [plan]).clients[0];
|
|
657
|
+
} };
|
|
658
|
+
}
|
|
659
|
+
/** Only an explicitly selected, receipt-matching standard registration is eligible. */
|
|
660
|
+
export function migrateLauncher(options, dryRun = false) {
|
|
661
|
+
const client = options.clients?.[0];
|
|
662
|
+
if (options.clients?.length !== 1 || !client || !['codex', 'claude'].includes(client) || options.automatic || options.mode)
|
|
663
|
+
return { status: 'partial', clients: [] };
|
|
664
|
+
try {
|
|
665
|
+
const plan = prepare(options, client, false, { migrate: true });
|
|
666
|
+
if (dryRun)
|
|
667
|
+
return { status: 'complete', clients: [{ client, status: 'unchanged', codes: [plan.changes.length ? 'launcher_migration_available' : 'launcher_current'], requires_hook_review: client === 'codex' }] };
|
|
668
|
+
const result = execute(options, false, { migrate: true }, [plan]);
|
|
669
|
+
if (result.status === 'complete')
|
|
670
|
+
result.clients[0].codes.push('launcher_migrated', 'mcp_restart_required');
|
|
671
|
+
return result;
|
|
672
|
+
}
|
|
673
|
+
catch (error) {
|
|
674
|
+
return { status: 'partial', clients: [{ client, status: 'error', codes: [safeCode(error)] }] };
|
|
675
|
+
}
|
|
676
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -71,6 +71,9 @@ export interface ProviderAnswer {
|
|
|
71
71
|
confidence?: number;
|
|
72
72
|
usage?: unknown;
|
|
73
73
|
}
|
|
74
|
-
export type RoutingProvider = (args: ProviderArguments) => Promise<ProviderAnswer
|
|
74
|
+
export type RoutingProvider = ((args: ProviderArguments) => Promise<ProviderAnswer>) & {
|
|
75
|
+
/** Synchronous local readiness check only; must not initiate transport. */
|
|
76
|
+
preflight?: () => void;
|
|
77
|
+
};
|
|
75
78
|
export declare function isRecord(value: unknown): value is UnknownRecord;
|
|
76
79
|
export declare function errorCode(error: unknown): string | undefined;
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.2";
|
package/dist/src/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.1.
|
|
1
|
+
export const VERSION = '0.1.2';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { win32 } from 'node:path';
|
|
2
|
+
/** Use the operator's Windows system directory, never cwd or PATH lookup. */
|
|
3
|
+
export function windowsPowerShellPath(env = process.env) {
|
|
4
|
+
const root = env.SystemRoot ?? 'C:\\Windows';
|
|
5
|
+
// Reject drive-relative, UNC/device, traversal and control-character roots.
|
|
6
|
+
if (!/^[A-Za-z]:[\\/]/.test(root) || /[\x00-\x1f\x7f]/.test(root) ||
|
|
7
|
+
root.slice(2).includes(':') || root.split(/[\\/]/).some(part => part === '.' || part === '..')) {
|
|
8
|
+
throw new Error('windows_system_directory_invalid');
|
|
9
|
+
}
|
|
10
|
+
return win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type Runner = (executable: string, args: readonly string[], input: string) => string;
|
|
2
|
+
interface WindowsPrivateOptions {
|
|
3
|
+
execute?: Runner;
|
|
4
|
+
}
|
|
5
|
+
/** Preserve the existing file DACL, including inheritance protection, before writing stage content. */
|
|
6
|
+
export declare function copyWindowsFilePermissions(source: string, target: string, options?: WindowsPrivateOptions): void;
|
|
7
|
+
/** Existing paths are inspected, never repaired or granted new permissions. */
|
|
8
|
+
export declare function assertWindowsPrivatePath(path: string, options?: WindowsPrivateOptions): void;
|
|
9
|
+
/** Only call immediately after this process exclusively created the directory. */
|
|
10
|
+
export declare function protectNewWindowsPrivateDirectory(path: string, options?: WindowsPrivateOptions): void;
|
|
11
|
+
export {};
|