aptly 0.1.0 → 0.1.1

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.
@@ -0,0 +1,263 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { windowsPowerShellPath } from './windows-powershell.js';
3
+ const fail = (code) => { throw new Error(code); };
4
+ const validName = (value) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,254}$/.test(value);
5
+ const validValue = (value) => typeof value === 'string' && !!value && value.trim() === value && Buffer.byteLength(value) <= 8192 && !/[\x00-\x1f\x7f]/.test(value);
6
+ const runner = (command, args, options) => spawnSync(command, args, options);
7
+ // Keychain Services generic-password dictionary keys are stable API values.
8
+ // Literal NSString keys avoid JXA's opaque CFString global-constant wrappers.
9
+ // Rebinding CopyMatching's output as id* bridges its NSData result correctly.
10
+ const keychainScript = String.raw `ObjC.import('Foundation');
11
+ ObjC.import('Security');
12
+ ObjC.bindFunction('SecItemCopyMatching', ['int', ['id', 'id*']]);
13
+ const input = JSON.parse(ObjC.unwrap($.NSString.alloc.initWithDataEncoding($.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile, $.NSUTF8StringEncoding)));
14
+ const query = $.NSMutableDictionary.alloc.init;
15
+ query.setObjectForKey($('genp'), $('class'));
16
+ query.setObjectForKey($(input.service), $('svce'));
17
+ query.setObjectForKey($(input.account), $('acct'));
18
+ let status = 0;
19
+ let value = null;
20
+ if (input.action === 'store') {
21
+ const data = $(input.value).dataUsingEncoding($.NSUTF8StringEncoding);
22
+ const changes = $.NSMutableDictionary.alloc.init;
23
+ changes.setObjectForKey(data, $('v_Data'));
24
+ status = $.SecItemUpdate(query, changes);
25
+ if (status === -25300) {
26
+ query.setObjectForKey(data, $('v_Data'));
27
+ status = $.SecItemAdd(query, null);
28
+ }
29
+ } else if (input.action === 'load') {
30
+ query.setObjectForKey($(true), $('r_Data'));
31
+ query.setObjectForKey($('m_LimitOne'), $('m_Limit'));
32
+ const result = Ref();
33
+ status = $.SecItemCopyMatching(query, result);
34
+ if (status === 0) value = ObjC.unwrap($.NSString.alloc.initWithDataEncoding(result[0], $.NSUTF8StringEncoding));
35
+ } else if (input.action === 'delete') {
36
+ status = $.SecItemDelete(query);
37
+ } else {
38
+ status = -50;
39
+ }
40
+ const output = $(JSON.stringify({ status, value })).dataUsingEncoding($.NSUTF8StringEncoding);
41
+ $.NSFileHandle.fileHandleWithStandardOutput.writeData(output);`;
42
+ // Windows generic credentials belong to the current token's credential set.
43
+ // The script is constant: target metadata and secret values arrive over stdin.
44
+ // CREDENTIALW layout/blob limit: learn.microsoft.com/windows/win32/api/wincred/ns-wincred-credentialw
45
+ const windowsScript = String.raw `$ErrorActionPreference = 'Stop'
46
+ $ProgressPreference = 'SilentlyContinue'
47
+ [Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false)
48
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
49
+ $status = -1
50
+ $value = $null
51
+ try {
52
+ $request = [Console]::In.ReadToEnd() | ConvertFrom-Json
53
+ Add-Type -TypeDefinition @'
54
+ using System;
55
+ using System.Text;
56
+ using System.Runtime.InteropServices;
57
+ public static class AptlyCredentialStore {
58
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
59
+ private struct Credential {
60
+ public uint Flags;
61
+ public uint Type;
62
+ [MarshalAs(UnmanagedType.LPWStr)] public string TargetName;
63
+ [MarshalAs(UnmanagedType.LPWStr)] public string Comment;
64
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
65
+ public uint CredentialBlobSize;
66
+ public IntPtr CredentialBlob;
67
+ public uint Persist;
68
+ public uint AttributeCount;
69
+ public IntPtr Attributes;
70
+ [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias;
71
+ [MarshalAs(UnmanagedType.LPWStr)] public string UserName;
72
+ }
73
+ [DllImport("Advapi32.dll", EntryPoint="CredWriteW", CharSet=CharSet.Unicode, ExactSpelling=true, SetLastError=true)]
74
+ [return: MarshalAs(UnmanagedType.Bool)]
75
+ private static extern bool CredWrite(ref Credential credential, uint flags);
76
+ [DllImport("Advapi32.dll", EntryPoint="CredReadW", CharSet=CharSet.Unicode, ExactSpelling=true, SetLastError=true)]
77
+ [return: MarshalAs(UnmanagedType.Bool)]
78
+ private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential);
79
+ [DllImport("Advapi32.dll", EntryPoint="CredDeleteW", CharSet=CharSet.Unicode, ExactSpelling=true, SetLastError=true)]
80
+ [return: MarshalAs(UnmanagedType.Bool)]
81
+ private static extern bool CredDelete(string target, uint type, uint flags);
82
+ [DllImport("Advapi32.dll", ExactSpelling=true)]
83
+ private static extern void CredFree(IntPtr credential);
84
+ private static readonly UTF8Encoding Utf8 = new UTF8Encoding(false, true);
85
+ public static int Store(string target, string value) {
86
+ byte[] bytes = Utf8.GetBytes(value);
87
+ IntPtr blob = IntPtr.Zero;
88
+ try {
89
+ if (bytes.Length == 0 || bytes.Length > 2560) return 87;
90
+ blob = Marshal.AllocHGlobal(bytes.Length);
91
+ Marshal.Copy(bytes, 0, blob, bytes.Length);
92
+ Credential credential = new Credential();
93
+ credential.Type = 1;
94
+ credential.TargetName = target;
95
+ credential.UserName = "Aptly";
96
+ credential.CredentialBlobSize = (uint)bytes.Length;
97
+ credential.CredentialBlob = blob;
98
+ credential.Persist = 2;
99
+ return CredWrite(ref credential, 0) ? 0 : Marshal.GetLastWin32Error();
100
+ } finally {
101
+ if (blob != IntPtr.Zero) {
102
+ for (int i = 0; i < bytes.Length; i++) Marshal.WriteByte(blob, i, 0);
103
+ Marshal.FreeHGlobal(blob);
104
+ }
105
+ Array.Clear(bytes, 0, bytes.Length);
106
+ }
107
+ }
108
+ public static int Load(string target, out string value) {
109
+ value = null;
110
+ IntPtr pointer = IntPtr.Zero;
111
+ byte[] bytes = null;
112
+ try {
113
+ if (!CredRead(target, 1, 0, out pointer)) return Marshal.GetLastWin32Error();
114
+ Credential credential = (Credential)Marshal.PtrToStructure(pointer, typeof(Credential));
115
+ if (credential.CredentialBlobSize == 0 || credential.CredentialBlobSize > 2560 || credential.CredentialBlob == IntPtr.Zero) return 87;
116
+ bytes = new byte[(int)credential.CredentialBlobSize];
117
+ Marshal.Copy(credential.CredentialBlob, bytes, 0, bytes.Length);
118
+ value = Utf8.GetString(bytes);
119
+ return 0;
120
+ } finally {
121
+ if (bytes != null) Array.Clear(bytes, 0, bytes.Length);
122
+ if (pointer != IntPtr.Zero) CredFree(pointer);
123
+ }
124
+ }
125
+ public static int Delete(string target) {
126
+ return CredDelete(target, 1, 0) ? 0 : Marshal.GetLastWin32Error();
127
+ }
128
+ }
129
+ '@
130
+ $target = [string]$request.service + ':' + [string]$request.account
131
+ switch ($request.action) {
132
+ 'store' { $status = [AptlyCredentialStore]::Store($target, [string]$request.value) }
133
+ 'load' { $status = [AptlyCredentialStore]::Load($target, [ref]$value) }
134
+ 'delete' { $status = [AptlyCredentialStore]::Delete($target) }
135
+ }
136
+ } catch {
137
+ $status = -1
138
+ $value = $null
139
+ }
140
+ [Console]::Out.WriteLine((@{status=$status;value=$value} | ConvertTo-Json -Compress))`;
141
+ function execute(command, args, input, options) {
142
+ let result;
143
+ try {
144
+ result = (options.exec ?? runner)(command, args, { input, encoding: 'utf8', timeout: 15000, maxBuffer: 65536, stdio: ['pipe', 'pipe', 'pipe'] });
145
+ }
146
+ catch {
147
+ return fail('secure_store_failed');
148
+ }
149
+ const errorCode = result.error && 'code' in result.error ? result.error.code : undefined;
150
+ if (errorCode === 'ENOENT')
151
+ fail('secure_store_unavailable');
152
+ if (errorCode === 'ETIMEDOUT')
153
+ fail('secure_store_timeout');
154
+ if (result.error)
155
+ fail('secure_store_failed');
156
+ return result;
157
+ }
158
+ function operate(action, value, options) {
159
+ const service = options.service ?? 'dev.aptly.mcp', account = options.account ?? 'OPENAI_API_KEY';
160
+ if (!validName(service) || !validName(account))
161
+ fail('secure_store_invalid_options');
162
+ if (action === 'store' && !validValue(value))
163
+ fail('secure_store_invalid_value');
164
+ const platform = options.platform ?? process.platform;
165
+ if (platform === 'win32') {
166
+ if (action === 'store' && Buffer.byteLength(value) > 2560)
167
+ fail('secure_store_invalid_value');
168
+ const input = JSON.stringify({ action, service, account, ...(action === 'store' ? { value } : {}) });
169
+ let executable;
170
+ try {
171
+ executable = windowsPowerShellPath();
172
+ }
173
+ catch {
174
+ return fail('secure_store_unavailable');
175
+ }
176
+ const result = execute(executable, ['-NoProfile', '-NonInteractive', '-Command', windowsScript], input, options);
177
+ if (result.status !== 0)
178
+ fail('secure_store_failed');
179
+ let parsed;
180
+ try {
181
+ parsed = JSON.parse(String(result.stdout ?? ''));
182
+ }
183
+ catch {
184
+ return fail('secure_store_failed');
185
+ }
186
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
187
+ fail('secure_store_failed');
188
+ const response = parsed;
189
+ if (response.status === 1168 && action !== 'store')
190
+ return null;
191
+ if (response.status === 5)
192
+ fail('secure_store_denied');
193
+ if (response.status === 1223)
194
+ fail('secure_store_cancelled');
195
+ if (response.status === 1312 || response.status === 50)
196
+ fail('secure_store_unavailable');
197
+ if (response.status !== 0)
198
+ fail('secure_store_failed');
199
+ if (action !== 'load')
200
+ return null;
201
+ const loaded = response.value;
202
+ if (!validValue(loaded) || Buffer.byteLength(loaded) > 2560)
203
+ return fail('secure_store_invalid_value');
204
+ return loaded;
205
+ }
206
+ if (platform === 'darwin') {
207
+ const input = JSON.stringify({ action, service, account, ...(action === 'store' ? { value } : {}) });
208
+ const result = execute('/usr/bin/osascript', ['-l', 'JavaScript', '-e', keychainScript], input, options);
209
+ if (result.status !== 0)
210
+ fail('secure_store_failed');
211
+ let parsed;
212
+ try {
213
+ parsed = JSON.parse(String(result.stdout ?? ''));
214
+ }
215
+ catch {
216
+ return fail('secure_store_failed');
217
+ }
218
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
219
+ fail('secure_store_failed');
220
+ const response = parsed;
221
+ if (response.status === -25300) {
222
+ if (action === 'store')
223
+ fail('secure_store_unavailable');
224
+ return null;
225
+ }
226
+ if (response.status === -128)
227
+ fail('secure_store_cancelled');
228
+ if (response.status === -25293)
229
+ fail('secure_store_denied');
230
+ if (response.status === -25308)
231
+ fail('secure_store_locked');
232
+ if (response.status === -34018)
233
+ fail('secure_store_unavailable');
234
+ if (response.status !== 0)
235
+ fail('secure_store_failed');
236
+ if (action !== 'load')
237
+ return null;
238
+ const loaded = response.value;
239
+ if (!validValue(loaded))
240
+ return fail('secure_store_invalid_value');
241
+ return loaded;
242
+ }
243
+ if (platform === 'linux') {
244
+ const args = action === 'store' ? ['store', '--label=Aptly MCP classification key', 'service', service, 'account', account] : [action === 'load' ? 'lookup' : 'clear', 'service', service, 'account', account];
245
+ const result = execute('secret-tool', args, action === 'store' ? value + '\n' : undefined, options);
246
+ if (result.status === 1 && !String(result.stderr ?? '').trim() && (action === 'delete' || (action === 'load' && !String(result.stdout ?? '').trim())))
247
+ return null;
248
+ if (result.status !== 0)
249
+ fail('secure_store_failed');
250
+ if (action !== 'load')
251
+ return null;
252
+ const loaded = String(result.stdout ?? '').replace(/\r?\n$/, '');
253
+ if (!validValue(loaded))
254
+ fail('secure_store_invalid_value');
255
+ return loaded;
256
+ }
257
+ return fail('secure_store_unsupported_platform');
258
+ }
259
+ /** Secret values are sent to OS tools only over stdin, never command arguments. */
260
+ export function storeSecret(value, options = {}) { operate('store', value, options); }
261
+ /** Internal runtime API: callers must never echo the returned value. */
262
+ export function loadSecret(options = {}) { return operate('load', undefined, options); }
263
+ export function deleteSecret(options = {}) { operate('delete', undefined, options); }
@@ -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', {
@@ -17,6 +17,11 @@ export interface SetupResult {
17
17
  status: 'complete' | 'partial' | 'skipped';
18
18
  clients: SetupClientResult[];
19
19
  }
20
+ export declare const configurableEnvironmentKeys: readonly string[];
20
21
  export declare function setupHosts(options?: SetupOptions): SetupResult;
21
22
  export declare function removeSetup(options?: SetupOptions): SetupResult;
23
+ export declare function setDefaults(options?: SetupOptions): SetupResult;
24
+ export declare function setEnvironment(key: string, value: string, options?: SetupOptions): SetupResult;
25
+ /** Store only the nonsecret source reference; credential values never enter setup. */
26
+ export declare function setCredentialReference(options?: SetupOptions): SetupResult;
22
27
  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 {
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,7 +248,23 @@ function ownership(value, client) {
154
248
  fail('invalid_ownership');
155
249
  return value;
156
250
  }
157
- function prepare(options, client, removing) {
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
+ if (entry.command !== process.execPath || !Array.isArray(entry.args) || entry.args.length !== 1 || !allowed.includes(entry.args[0]))
255
+ fail('credential_launcher_unverified');
256
+ try {
257
+ const extension = entry.args[0].endsWith('.ts') ? 'ts' : 'js';
258
+ const server = read(entry.args[0]).text;
259
+ const runtime = read(join(dirname(entry.args[0]), 'runtime.' + extension)).text;
260
+ if (!server?.includes('./runtime.' + extension) || !runtime?.includes('APTLY_SECRET_BACKEND') || !runtime.includes('APTLY_SECRET_CLIENT'))
261
+ fail('credential_launcher_unverified');
262
+ }
263
+ catch {
264
+ fail('credential_launcher_unverified');
265
+ }
266
+ }
267
+ function prepare(options, client, removing, edit) {
158
268
  const env = options.env ?? process.env, paths = resolved(options, client), { dir, mcpPath, hooksPath, privateDir } = paths;
159
269
  const changes = [], dirs = [];
160
270
  noSymlinks(dir);
@@ -162,8 +272,7 @@ function prepare(options, client, removing) {
162
272
  for (const path of [privateDir, join(privateDir, 'state')]) {
163
273
  try {
164
274
  checkedDirectory(path);
165
- if ((lstatSync(path).mode & 0o077) !== 0)
166
- fail('private_directory_permissions');
275
+ privatePermissions(path, 'private_directory_permissions');
167
276
  }
168
277
  catch (e) {
169
278
  if (e.code !== 'ENOENT')
@@ -186,8 +295,8 @@ function prepare(options, client, removing) {
186
295
  const snap = (path) => { const source = read(path); return { path, before: source.text, after: source.text, mode: source.mode }; };
187
296
  const mcp = snap(mcpPath), hooks = snap(hooksPath), policy = snap(policyPath), manifest = snap(manifestPath);
188
297
  for (const privateFile of [policy, manifest])
189
- if (privateFile.before !== null && (privateFile.mode & 0o077) !== 0)
190
- fail('private_file_permissions');
298
+ if (privateFile.before !== null)
299
+ privatePermissions(privateFile.path, 'private_file_permissions');
191
300
  const oldOwner = manifest.before === null ? undefined : ownership(json(manifest.before), client);
192
301
  if (removing && !oldOwner)
193
302
  return { changes, dirs, result: { client, status: 'skipped', codes: ['no_owned_setup'] } };
@@ -247,17 +356,13 @@ function prepare(options, client, removing) {
247
356
  let created = false, tomlBlock;
248
357
  if (servers.aptly === undefined) {
249
358
  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);
359
+ 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
360
  }
258
361
  else {
259
362
  if (!object(servers.aptly))
260
363
  fail('mcp_entry_conflict');
364
+ if (edit && oldOwner?.mcp_created && !isDeepStrictEqual(servers.aptly, oldOwner.mcp))
365
+ fail('managed_mcp_changed');
261
366
  const currentEnv = map(servers.aptly, 'env');
262
367
  if (currentEnv.APTLY_HOOK_POLICY_PATH !== undefined && currentEnv.APTLY_HOOK_POLICY_PATH !== policyPath)
263
368
  fail('mcp_environment_conflict');
@@ -265,13 +370,29 @@ function prepare(options, client, removing) {
265
370
  Object.assign(currentEnv, missing);
266
371
  mcp.after = client === 'codex' ? addTomlEnv(mcp.before ?? '', missing) : stringify(config);
267
372
  }
373
+ if (edit && 'credential' in edit)
374
+ requireCredentialRuntime(servers.aptly, root);
375
+ const currentEnv = map(servers.aptly, 'env');
376
+ const updates = edit ? editEnvironment(currentEnv, edit, client) : {};
377
+ Object.assign(currentEnv, updates);
378
+ if (created) {
379
+ if (client === 'codex') {
380
+ tomlBlock = newTomlBlock(servers.aptly);
381
+ mcp.after = (mcp.before ?? '') + ((mcp.before ?? '').endsWith('\n') || !mcp.before ? '\n' : '\n\n') + tomlBlock;
382
+ }
383
+ else
384
+ mcp.after = stringify(config);
385
+ }
386
+ else if (edit)
387
+ mcp.after = client === 'codex' ? updateTomlEnv(mcp.after, updates) : stringify(config);
268
388
  const existingRoute = entries.filter(routeHook);
269
389
  if (existingRoute.some(entry => !isDeepStrictEqual(entry, hookValue)))
270
390
  fail('existing_routing_hook_conflict');
271
391
  if (existingRoute.length === 0)
272
392
  entries.push(hookValue);
273
393
  hooks.after = stringify(hooksObject);
274
- const policyValue = { schema_version: 1, kind: 'aptly-auto-hook', mode: options.mode ?? 'defaults', issuer: 'operator_installation', enabled: true, state_directory: stateDir };
394
+ const preservedMode = edit && policy.before !== null ? json(policy.before).mode : undefined;
395
+ 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
396
  if (policy.before !== null) {
276
397
  const p = json(policy.before);
277
398
  if (!isDeepStrictEqual(p, policyValue))
@@ -286,6 +407,36 @@ function prepare(options, client, removing) {
286
407
  if (existingRoute.length && !oldOwner)
287
408
  fail('existing_hook_not_owned');
288
409
  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 } : {}) };
410
+ if (edit && oldOwner) {
411
+ if (oldOwner.mcp_created) {
412
+ owner.mcp = structuredClone(servers.aptly);
413
+ if (client === 'codex') {
414
+ const oldBlock = oldOwner.toml_block;
415
+ if (!oldBlock || !(mcp.before ?? '').includes(oldBlock))
416
+ fail('managed_mcp_changed');
417
+ const nextBlock = newTomlBlock(servers.aptly);
418
+ const rendered = (mcp.before ?? '').replace(oldBlock, nextBlock);
419
+ if (!isDeepStrictEqual(toml(rendered), config))
420
+ fail('managed_mcp_changed');
421
+ mcp.after = rendered;
422
+ owner.toml_block = nextBlock;
423
+ }
424
+ }
425
+ else {
426
+ const prior = client === 'codex' ? toml(mcp.before ?? '') : json(mcp.before);
427
+ const priorEnv = prior[client === 'codex' ? 'mcp_servers' : 'mcpServers']?.aptly?.env ?? {};
428
+ for (const [key, value] of Object.entries(updates))
429
+ if (Object.hasOwn(owner.env_added, key) || priorEnv[key] === undefined)
430
+ owner.env_added[key] = value;
431
+ }
432
+ }
433
+ else if (edit && !created) {
434
+ const prior = client === 'codex' ? toml(mcp.before ?? '') : json(mcp.before);
435
+ const priorEnv = prior[client === 'codex' ? 'mcp_servers' : 'mcpServers']?.aptly?.env ?? {};
436
+ for (const [key, value] of Object.entries(updates))
437
+ if (priorEnv[key] === undefined)
438
+ owner.env_added[key] = value;
439
+ }
289
440
  manifest.after = stringify(owner);
290
441
  if (client === 'codex')
291
442
  result.requires_hook_review = true;
@@ -316,13 +467,21 @@ function replace(snapshot, forward) {
316
467
  let fd;
317
468
  try {
318
469
  fd = openSync(stage, 'wx', 0o600);
470
+ if (process.platform === 'win32' && expected !== null) {
471
+ try {
472
+ copyWindowsFilePermissions(snapshot.path, stage);
473
+ }
474
+ catch {
475
+ fail('private_file_permissions');
476
+ }
477
+ }
319
478
  writeFileSync(fd, next);
320
479
  closeSync(fd);
321
480
  fd = undefined;
322
481
  if (read(snapshot.path).text !== expected)
323
482
  fail('concurrent_configuration_change');
324
483
  renameSync(stage, snapshot.path);
325
- if (snapshot.mode !== 0o600)
484
+ if (process.platform !== 'win32' && snapshot.mode !== 0o600)
326
485
  chmodSync(snapshot.path, snapshot.mode);
327
486
  }
328
487
  finally {
@@ -337,7 +496,7 @@ function replace(snapshot, forward) {
337
496
  }
338
497
  }
339
498
  }
340
- function execute(options, removing) {
499
+ function execute(options, removing, edit) {
341
500
  const clients = [...new Set(options.clients ?? ['codex', 'claude'])];
342
501
  if (clients.some(client => client !== 'codex' && client !== 'claude'))
343
502
  return { status: 'partial', clients: [] };
@@ -346,13 +505,15 @@ function execute(options, removing) {
346
505
  return { status: 'skipped', clients: clients.map(client => ({ client, status: 'skipped', codes: [env.APTLY_SKIP_SETUP === '1' ? 'setup_disabled' : 'ci_environment'] })) };
347
506
  const prepared = clients.map(client => {
348
507
  try {
349
- return prepare(options, client, removing);
508
+ return prepare(options, client, removing, edit);
350
509
  }
351
510
  catch (e) {
352
511
  return { changes: [], dirs: [], result: { client, status: 'error', codes: [safeCode(e)] } };
353
512
  }
354
513
  });
355
514
  const results = prepared.map(plan => plan.result);
515
+ if (edit && results.some(result => result.status === 'error'))
516
+ return { status: 'partial', clients: results.map(result => result.status === 'error' ? result : { ...result, status: 'skipped', codes: ['other_host_validation_failed'] }) };
356
517
  // Each host is independent; prepare every host before committing any host.
357
518
  for (const plan of prepared) {
358
519
  if (!plan.changes.length)
@@ -369,11 +530,17 @@ function execute(options, removing) {
369
530
  throw e;
370
531
  mkdirSync(path, { mode: 0o700 });
371
532
  madeDirs.push(path);
533
+ if (process.platform === 'win32') {
534
+ try {
535
+ protectNewWindowsPrivateDirectory(path);
536
+ }
537
+ catch {
538
+ fail('private_directory_permissions');
539
+ }
540
+ }
372
541
  }
373
- if (path.endsWith('/aptly') || path.endsWith('/aptly/state')) {
374
- if ((lstatSync(path).mode & 0o077) !== 0)
375
- fail('private_directory_permissions');
376
- }
542
+ if (basename(path) === 'aptly' || (basename(path) === 'state' && basename(dirname(path)) === 'aptly'))
543
+ privatePermissions(path, 'private_directory_permissions');
377
544
  }
378
545
  for (const snapshot of plan.changes) {
379
546
  replace(snapshot, true);
@@ -402,7 +569,19 @@ function execute(options, removing) {
402
569
  }
403
570
  return { status: results.some(result => result.status === 'error') ? 'partial' : results.every(result => result.status === 'skipped') ? 'skipped' : 'complete', clients: results };
404
571
  }
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']);
572
+ const codes = new Set(['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
573
  function safeCode(error) { return error instanceof Error && codes.has(error.message) ? error.message : 'configuration_io_error'; }
407
574
  export function setupHosts(options = {}) { return execute(options, false); }
408
575
  export function removeSetup(options = {}) { return execute(options, true); }
576
+ export function setDefaults(options = {}) { return execute(options, false, { defaults: true }); }
577
+ export function setEnvironment(key, value, options = {}) {
578
+ try {
579
+ validateEnvironmentValue(key, value);
580
+ }
581
+ catch (error) {
582
+ return { status: 'partial', clients: (options.clients ?? ['codex', 'claude']).map(client => ({ client, status: 'error', codes: [safeCode(error)] })) };
583
+ }
584
+ return execute(options, false, { key, value });
585
+ }
586
+ /** Store only the nonsecret source reference; credential values never enter setup. */
587
+ export function setCredentialReference(options = {}) { return execute(options, false, { credential: true }); }
@@ -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;
@@ -1 +1 @@
1
- export declare const VERSION = "0.1.0";
1
+ export declare const VERSION = "0.1.1";
@@ -1 +1 @@
1
- export const VERSION = '0.1.0';
1
+ export const VERSION = '0.1.1';
@@ -0,0 +1,2 @@
1
+ /** Use the operator's Windows system directory, never cwd or PATH lookup. */
2
+ export declare function windowsPowerShellPath(env?: NodeJS.ProcessEnv): string;