@milpa/comal 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.md +30 -28
- package/backend/contracts/remote-commands-v1.json +125 -0
- package/bin/comal.mjs +16 -5
- package/docs/investigation.md +2 -2
- package/governance/chrome-task-v2.json +67 -0
- package/node_modules/@jitl/quickjs-ffi-types/LICENSE +21 -0
- package/node_modules/@jitl/quickjs-ffi-types/README.md +5 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.mts +549 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.ts +549 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-ffi-types/package.json +36 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/README.md +93 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.cjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.mts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.ts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/package.json +61 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/README.md +88 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.mjs +5430 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cjs +5485 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.cjs +5424 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.mjs +5541 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.mts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.ts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/package.json +61 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/README.md +89 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.mjs +26 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cjs +25 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.cjs +25 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.mjs +30 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.mts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.ts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/package.json +61 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/README.md +82 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.mjs +22 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cjs +21 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.cjs +21 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.mjs +25 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.mts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.ts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/package.json +61 -0
- package/node_modules/quickjs-emscripten/LICENSE +21 -0
- package/node_modules/quickjs-emscripten/README.md +928 -0
- package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs +2 -0
- package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs.map +1 -0
- package/node_modules/quickjs-emscripten/dist/index.d.mts +57 -0
- package/node_modules/quickjs-emscripten/dist/index.d.ts +57 -0
- package/node_modules/quickjs-emscripten/dist/index.global.js +7309 -0
- package/node_modules/quickjs-emscripten/dist/index.js +2 -0
- package/node_modules/quickjs-emscripten/dist/index.js.map +1 -0
- package/node_modules/quickjs-emscripten/dist/index.mjs +2 -0
- package/node_modules/quickjs-emscripten/dist/index.mjs.map +1 -0
- package/node_modules/quickjs-emscripten/dist/variants.d.mts +39 -0
- package/node_modules/quickjs-emscripten/dist/variants.d.ts +39 -0
- package/node_modules/quickjs-emscripten/dist/variants.js +2 -0
- package/node_modules/quickjs-emscripten/dist/variants.js.map +1 -0
- package/node_modules/quickjs-emscripten/dist/variants.mjs +2 -0
- package/node_modules/quickjs-emscripten/dist/variants.mjs.map +1 -0
- package/node_modules/quickjs-emscripten/package.json +69 -0
- package/node_modules/quickjs-emscripten-core/LICENSE +21 -0
- package/node_modules/quickjs-emscripten-core/README.md +341 -0
- package/node_modules/quickjs-emscripten-core/README.template.md +70 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs +7 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/index.d.mts +2033 -0
- package/node_modules/quickjs-emscripten-core/dist/index.d.ts +2033 -0
- package/node_modules/quickjs-emscripten-core/dist/index.js +7 -0
- package/node_modules/quickjs-emscripten-core/dist/index.js.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/index.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/index.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/package.json +49 -0
- package/package.json +6 -2
- package/src/agent.mjs +17 -6
- package/src/artifact-tools.mjs +3 -2
- package/src/auto-guard.mjs +5 -1
- package/src/cli.mjs +267 -201
- package/src/cloud-client.mjs +58 -8
- package/src/code-run-worker.mjs +70 -0
- package/src/code-run.mjs +97 -0
- package/src/codex-provider.mjs +28 -28
- package/src/compaction.mjs +1 -1
- package/src/goals.mjs +2 -2
- package/src/house.mjs +24 -24
- package/src/i18n/cli-es.mjs +97 -0
- package/src/i18n/es.mjs +227 -0
- package/src/i18n/index.mjs +48 -0
- package/src/image-attachments.mjs +263 -0
- package/src/model-controls.mjs +4 -4
- package/src/onboarding.mjs +91 -0
- package/src/openai-provider.mjs +24 -24
- package/src/peer-tools.mjs +4 -3
- package/src/permissions.mjs +53 -17
- package/src/provider-auth.mjs +30 -30
- package/src/provider-profiles.mjs +27 -27
- package/src/provider.mjs +30 -30
- package/src/remote-commands.mjs +11 -0
- package/src/remote-control.mjs +180 -0
- package/src/sessions.mjs +3 -3
- package/src/subagents.mjs +7 -3
- package/src/tool-packs.mjs +5 -2
- package/src/tools.mjs +92 -34
- package/src/tui/brand.mjs +2 -1
- package/src/tui/clipboard.mjs +5 -4
- package/src/tui/composer.mjs +77 -11
- package/src/tui/controller.mjs +111 -29
- package/src/tui/discovery.mjs +105 -100
- package/src/tui/markdown.mjs +10 -9
- package/src/tui/message-queue.mjs +27 -9
- package/src/tui/screen.mjs +209 -118
- package/src/vision.mjs +31 -3
- package/src/work-assertions.mjs +7 -7
- package/src/work-plan.mjs +189 -21
- package/src/work-recovery-proof.mjs +13 -1
- package/src/workflows/chrome-task.mjs +24 -7
package/src/permissions.mjs
CHANGED
|
@@ -5,53 +5,89 @@ import { inspectAutoAction, intentCoversAction } from './auto-guard.mjs';
|
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import { readOnlyBashCheck, chromeLabObservation } from './read-only-check.mjs';
|
|
7
7
|
READ_TOOLS.add('ToolPack');
|
|
8
|
-
for (const name of ['ArtifactAccount','ArtifactList','ArtifactInspect']) READ_TOOLS.add(name);
|
|
8
|
+
for (const name of ['ArtifactAccount','ArtifactCapabilities','ArtifactList','ArtifactInspect']) READ_TOOLS.add(name);
|
|
9
9
|
for (const name of ['ListDirectory', 'FileInfo']) READ_TOOLS.add(name);
|
|
10
10
|
for (const name of ['ListAgents','SendMessage','ProviderCapabilities','ProviderSearch','AnalyzeMedia']) READ_TOOLS.add(name);
|
|
11
11
|
|
|
12
12
|
export const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'auto'];
|
|
13
13
|
export function nextPermissionMode(mode) { return PERMISSION_MODES[(PERMISSION_MODES.indexOf(mode) + 1) % PERMISSION_MODES.length]; }
|
|
14
14
|
|
|
15
|
+
// A UI callback may be shared by resident and subagent policies. One visible
|
|
16
|
+
// question at a time prevents another invocation from replacing its authority.
|
|
17
|
+
const confirmationQueues = new WeakMap();
|
|
18
|
+
function waitWithAbort(promise, signal) {
|
|
19
|
+
const pending=Promise.resolve(promise);
|
|
20
|
+
if (!signal) return pending;
|
|
21
|
+
if(signal.aborted){pending.catch(()=>{});return Promise.reject(signal.reason);}
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
let settled=false;
|
|
24
|
+
const finish = (callback, value) => { if(settled)return;settled=true;signal.removeEventListener('abort', abort); callback(value); };
|
|
25
|
+
const abort = () => finish(reject, signal.reason);
|
|
26
|
+
signal.addEventListener('abort', abort, {once:true});
|
|
27
|
+
pending.then(value => finish(resolve,value), error => finish(reject,error));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function permissionStamp(policy) {
|
|
31
|
+
return JSON.stringify([policy.mode,[...policy.allowed].sort(),[...policy.denied].sort(),policy.context?.intentions]);
|
|
32
|
+
}
|
|
33
|
+
|
|
15
34
|
export class PermissionPolicy {
|
|
16
35
|
constructor({ mode = 'default', allowed = [], denied = [], confirm, classifier, cwd, onDecision = () => {} } = {}) {
|
|
17
|
-
if (!PERMISSION_MODES.includes(mode)) throw new Error('
|
|
36
|
+
if (!PERMISSION_MODES.includes(mode)) throw new Error('Invalid permission mode. Use default, plan, acceptEdits or auto.');
|
|
18
37
|
this.mode = mode; this.allowed = new Set(allowed); this.denied = new Set(denied); this.confirm = confirm;
|
|
19
38
|
this.classifier = classifier; this.cwd = cwd; this.onDecision = onDecision; this.context = { intentions: [], actions: [] };
|
|
20
39
|
}
|
|
21
40
|
setContext(context) { this.context = context; }
|
|
22
41
|
async authorize(name, input, { signal, onWaiting } = {}) {
|
|
23
42
|
signal?.throwIfAborted();
|
|
24
|
-
if (this.denied.has(name)) return { allowed: false, reason: '
|
|
43
|
+
if (this.denied.has(name)) return { allowed: false, reason: 'Explicit denial rule' };
|
|
25
44
|
const knownRead = READ_TOOLS.has(name) || this.allowed.has(name) && chromeLabObservation(name);
|
|
26
|
-
if (name === 'Agent' || name === 'AgentBatch') return { allowed: true, reason: '
|
|
27
|
-
if (this.mode === 'plan' && !knownRead) return { allowed: false, reason: '
|
|
28
|
-
if (name === 'Bash' && readOnlyBashCheck(input.command)) return { allowed: true, reason: '
|
|
45
|
+
if (name === 'Agent' || name === 'AgentBatch') return { allowed: true, reason: 'Delegation: every child action retains the session permissions' };
|
|
46
|
+
if (this.mode === 'plan' && !knownRead) return { allowed: false, reason: 'Plan mode: read-only' };
|
|
47
|
+
if (name === 'Bash' && readOnlyBashCheck(input.command)) return { allowed: true, reason: 'Native read-only check with fixed argv; no classifier required.' };
|
|
29
48
|
if (this.mode === 'auto' && !knownRead) return this.authorizeAuto(name, input, signal, onWaiting);
|
|
30
|
-
if (knownRead || this.allowed.has(name) || (this.mode === 'acceptEdits' && EDIT_TOOLS.has(name))) return { allowed: true, reason: '
|
|
49
|
+
if (knownRead || this.allowed.has(name) || (this.mode === 'acceptEdits' && EDIT_TOOLS.has(name))) return { allowed: true, reason: 'Session policy' };
|
|
31
50
|
const allowed = await this.ask(name, input, signal, onWaiting);
|
|
32
|
-
return { allowed, reason: allowed ? '
|
|
51
|
+
return { allowed, reason: allowed ? 'User confirmation' : 'No authorization for this tool' };
|
|
33
52
|
}
|
|
34
53
|
async ask(name, input, signal, onWaiting) {
|
|
35
54
|
if (!this.confirm) return false;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
const confirm=this.confirm, stamp=permissionStamp(this), previous=confirmationQueues.get(confirm) || Promise.resolve();
|
|
56
|
+
let release;
|
|
57
|
+
const slot=new Promise(resolve => {release=resolve;});
|
|
58
|
+
const tail=previous.catch(() => {}).then(() => slot);
|
|
59
|
+
confirmationQueues.set(confirm,tail);
|
|
60
|
+
const questionId=randomUUID();let allowed=false,displayed=false;
|
|
61
|
+
try {
|
|
62
|
+
await waitWithAbort(previous,signal);
|
|
63
|
+
signal?.throwIfAborted();
|
|
64
|
+
// Do not show a stale question. House observes the changed authority and
|
|
65
|
+
// calls authorize again, so newly denied/plan actions cannot reach a UI.
|
|
66
|
+
if(this.confirm!==confirm || stamp!==permissionStamp(this))return false;
|
|
67
|
+
await onWaiting?.({type:'house_waiting',question_id:questionId});
|
|
68
|
+
displayed=true;signal?.throwIfAborted();
|
|
69
|
+
allowed=Boolean(await waitWithAbort(confirm(name,input,{signal,questionId}),signal));
|
|
70
|
+
signal?.throwIfAborted();
|
|
71
|
+
return stamp===permissionStamp(this) && this.confirm===confirm ? allowed : false;
|
|
72
|
+
} finally {
|
|
73
|
+
try { if(displayed)await onWaiting?.({type:'house_answered',question_id:questionId,allowed:allowed && !signal?.aborted && stamp===permissionStamp(this)}); }
|
|
74
|
+
finally {release();if(confirmationQueues.get(confirm)===tail)tail.then(()=>{if(confirmationQueues.get(confirm)===tail)confirmationQueues.delete(confirm);});}
|
|
75
|
+
}
|
|
40
76
|
}
|
|
41
77
|
async authorizeAuto(name, input, signal, onWaiting) {
|
|
42
78
|
const inspection = inspectAutoAction(name, input, this.cwd || process.cwd());
|
|
43
79
|
const data = { workspace: this.cwd, human_requests: this.context.intentions, previous_tool_calls: this.context.actions, proposed_action: { name, input } };
|
|
44
80
|
let verdict;
|
|
45
81
|
if (inspection.hard) verdict = { decision: 'deny', risk: 'destructive', reason: inspection.hard, intent_quote: '', source: 'guard' };
|
|
46
|
-
else if (!this.classifier || !data.human_requests.length || Buffer.byteLength(JSON.stringify(data)) > 64000) verdict = { decision: 'ask', risk: 'unknown', reason: 'No
|
|
82
|
+
else if (!this.classifier || !data.human_requests.length || Buffer.byteLength(JSON.stringify(data)) > 64000) verdict = { decision: 'ask', risk: 'unknown', reason: 'No classifier or insufficient intent context for automatic approval.', intent_quote: '', source: 'guard' };
|
|
47
83
|
else try {
|
|
48
84
|
verdict = { ...await this.classifier.classify(data, signal), source: 'classifier' };
|
|
49
|
-
if (verdict.decision === 'allow' && (inspection.opaque || verdict.risk === 'unknown')) verdict = { ...verdict, decision: 'ask', reason: '
|
|
50
|
-
if (verdict.decision === 'allow' && (inspection.destructive || verdict.risk === 'destructive') && !intentCoversAction(verdict, data.proposed_action, inspection, data.human_requests)) verdict = { ...verdict, decision: 'deny', reason: '
|
|
51
|
-
if (verdict.decision === 'allow' && verdict.risk === 'external' && (!verdict.intent_quote || !data.human_requests.at(-1)?.includes(verdict.intent_quote))) verdict = { ...verdict, decision: 'ask', reason: '
|
|
85
|
+
if (verdict.decision === 'allow' && (inspection.opaque || verdict.risk === 'unknown')) verdict = { ...verdict, decision: 'ask', reason: 'The action contains expansion, opaque execution or unknown effects; review is required.' };
|
|
86
|
+
if (verdict.decision === 'allow' && (inspection.destructive || verdict.risk === 'destructive') && !intentCoversAction(verdict, data.proposed_action, inspection, data.human_requests)) verdict = { ...verdict, decision: 'deny', reason: 'The latest intent does not explicitly authorize the destructive action and its target.' };
|
|
87
|
+
if (verdict.decision === 'allow' && verdict.risk === 'external' && (!verdict.intent_quote || !data.human_requests.at(-1)?.includes(verdict.intent_quote))) verdict = { ...verdict, decision: 'ask', reason: 'Explicit authorization for the external effect is missing.' };
|
|
52
88
|
} catch (error) {
|
|
53
89
|
if (signal?.aborted || error.code === 'GOAL_BUDGET') throw error;
|
|
54
|
-
verdict = { decision: 'ask', risk: 'unknown', reason: `
|
|
90
|
+
verdict = { decision: 'ask', risk: 'unknown', reason: `Auto verifier unavailable: ${error.message}`, intent_quote: '', source: 'classifier-error' };
|
|
55
91
|
}
|
|
56
92
|
signal?.throwIfAborted();
|
|
57
93
|
if (this.mode !== 'auto') return this.authorize(name, input, { signal, onWaiting });
|
package/src/provider-auth.mjs
CHANGED
|
@@ -16,7 +16,7 @@ const failure = (message, code = 'PROVIDER_AUTH_FAILED') => Object.assign(new Er
|
|
|
16
16
|
function definition(name) {
|
|
17
17
|
const id = String(name || '').toLowerCase();
|
|
18
18
|
const found = AUTH_PROVIDERS.find(item => item.id === ({ grokia: 'grok', xai: 'grok', chatgpt: 'codex', 'kimi-code': 'kimi' }[id] || id));
|
|
19
|
-
if (!found) throw failure('
|
|
19
|
+
if (!found) throw failure('Unknown login provider: codex, kimi, minimax or grok.', 'PROVIDER_AUTH_UNKNOWN');
|
|
20
20
|
return found;
|
|
21
21
|
}
|
|
22
22
|
export function authProviderDefaults(name) {
|
|
@@ -28,7 +28,7 @@ function tokenClaims(token) {
|
|
|
28
28
|
}
|
|
29
29
|
function secret(value) { return typeof value === 'string' && value.length > 0 && value.length <= 32768 && !/[\x00-\x20\x7f]/.test(value); }
|
|
30
30
|
function tokenRecord(provider, payload, now, previous = {}) {
|
|
31
|
-
if (!secret(payload?.access_token)) throw failure('
|
|
31
|
+
if (!secret(payload?.access_token)) throw failure('The server did not return a valid access_token.');
|
|
32
32
|
const claims = tokenClaims(payload.access_token), identity = tokenClaims(payload.id_token || previous.idToken);
|
|
33
33
|
const auth = claims['https://api.openai.com/auth'] || identity['https://api.openai.com/auth'] || {};
|
|
34
34
|
const ttl = Number(payload.expires_in);
|
|
@@ -49,7 +49,7 @@ export class AuthManager {
|
|
|
49
49
|
async _directory() {
|
|
50
50
|
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
51
51
|
const info = await lstat(this.directory);
|
|
52
|
-
if (!info.isDirectory() || info.isSymbolicLink() || typeof process.getuid === 'function' && info.uid !== process.getuid()) throw failure('
|
|
52
|
+
if (!info.isDirectory() || info.isSymbolicLink() || typeof process.getuid === 'function' && info.uid !== process.getuid()) throw failure('Insecure credential directory.');
|
|
53
53
|
await chmod(this.directory, 0o700);
|
|
54
54
|
}
|
|
55
55
|
async _read(provider) {
|
|
@@ -57,14 +57,14 @@ export class AuthManager {
|
|
|
57
57
|
let handle;
|
|
58
58
|
try {
|
|
59
59
|
const dir = await lstat(this.directory);
|
|
60
|
-
if (!dir.isDirectory() || dir.isSymbolicLink() || dir.mode & 0o077 || typeof process.getuid === 'function' && dir.uid !== process.getuid()) throw failure('
|
|
60
|
+
if (!dir.isDirectory() || dir.isSymbolicLink() || dir.mode & 0o077 || typeof process.getuid === 'function' && dir.uid !== process.getuid()) throw failure('Insecure credential directory.');
|
|
61
61
|
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
62
62
|
const info = await handle.stat();
|
|
63
|
-
if (!info.isFile() || info.size > 65536 || info.mode & 0o077 || typeof process.getuid === 'function' && info.uid !== process.getuid()) throw failure('
|
|
63
|
+
if (!info.isFile() || info.size > 65536 || info.mode & 0o077 || typeof process.getuid === 'function' && info.uid !== process.getuid()) throw failure('Insecure credential file.');
|
|
64
64
|
const record = JSON.parse(await handle.readFile('utf8'));
|
|
65
|
-
if (record.version !== 1 || record.provider !== provider || !secret(record.accessToken)) throw failure('
|
|
65
|
+
if (record.version !== 1 || record.provider !== provider || !secret(record.accessToken)) throw failure('Invalid stored credentials.');
|
|
66
66
|
return record;
|
|
67
|
-
} catch (error) { if (error.code === 'ENOENT') return null; throw failure('
|
|
67
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw failure('Could not read private credentials.', 'PROVIDER_AUTH_STORE'); }
|
|
68
68
|
finally { await handle?.close(); }
|
|
69
69
|
}
|
|
70
70
|
async _write(record) {
|
|
@@ -84,11 +84,11 @@ export class AuthManager {
|
|
|
84
84
|
signal?.throwIfAborted();
|
|
85
85
|
try { await mkdir(lock, { mode: 0o700 }); break; }
|
|
86
86
|
catch (error) {
|
|
87
|
-
if (error.code !== 'EEXIST') throw failure('
|
|
87
|
+
if (error.code !== 'EEXIST') throw failure('Could not lock the login store.');
|
|
88
88
|
let info; try { info = await lstat(lock); } catch (e) { if (e.code === 'ENOENT') continue; throw e; }
|
|
89
|
-
if (!info.isDirectory() || info.isSymbolicLink()) throw failure('
|
|
89
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw failure('Insecure credential lock.');
|
|
90
90
|
if (Date.now() - info.mtimeMs > 120000) { await rmdir(lock).catch(() => {}); continue; }
|
|
91
|
-
if (Date.now() >= until) throw failure('
|
|
91
|
+
if (Date.now() >= until) throw failure('Another process is updating login; retry.', 'PROVIDER_AUTH_BUSY');
|
|
92
92
|
await delay(50, undefined, { signal });
|
|
93
93
|
}
|
|
94
94
|
}
|
|
@@ -101,8 +101,8 @@ export class AuthManager {
|
|
|
101
101
|
status: !record ? 'logged_out' : record.expiresAt && record.expiresAt <= this.now() ? 'expired' : record.method === 'device_code' ? 'authenticated' : 'configured',
|
|
102
102
|
...(record?.expiresAt ? { expiresAt: record.expiresAt } : {}),
|
|
103
103
|
...(p.apiKeyEnv ? { apiKeyEnv: p.apiKeyEnv } : {}), documentation: p.documentation,
|
|
104
|
-
...(p.experimental ? { experimental: true, note: 'OAuth compatible
|
|
105
|
-
...(p.id === 'grok' ? { note: '
|
|
104
|
+
...(p.experimental ? { experimental: true, note: 'OAuth compatible with the public Kimi CLI code; the documented route for external clients is a Kimi Code key (--api-key-env KIMI_CODE_API_KEY).' } : {}),
|
|
105
|
+
...(p.id === 'grok' ? { note: 'This API uses API billing; no public Grok subscription login was found for Comal.' } : {}) };
|
|
106
106
|
}
|
|
107
107
|
async _request(url, { signal, values, json = false, headers = {} } = {}) {
|
|
108
108
|
const requestSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000);
|
|
@@ -110,7 +110,7 @@ export class AuthManager {
|
|
|
110
110
|
try { response = await this.fetch(url, { method: 'POST', redirect: 'error', signal: requestSignal,
|
|
111
111
|
headers: { 'content-type': json ? 'application/json' : 'application/x-www-form-urlencoded', 'User-Agent': 'Comal/0.1.0', ...headers },
|
|
112
112
|
body: json ? JSON.stringify(values) : new URLSearchParams(values).toString() }); }
|
|
113
|
-
catch (error) { if (signal?.aborted) throw signal.reason; throw failure('
|
|
113
|
+
catch (error) { if (signal?.aborted) throw signal.reason; throw failure('Could not connect to the authentication server.'); }
|
|
114
114
|
let data;
|
|
115
115
|
try {
|
|
116
116
|
const reader = response.body?.getReader();
|
|
@@ -121,18 +121,18 @@ export class AuthManager {
|
|
|
121
121
|
data = JSON.parse(body + decoder.decode());
|
|
122
122
|
} finally { await reader.cancel(); }
|
|
123
123
|
}
|
|
124
|
-
catch { throw failure(`
|
|
124
|
+
catch { throw failure(`Invalid authentication response (HTTP ${response.status}).`); }
|
|
125
125
|
return { response, data };
|
|
126
126
|
}
|
|
127
127
|
async login(name, { signal, onEvent = () => {}, apiKeyEnv, secret: suppliedSecret } = {}) {
|
|
128
128
|
const p = definition(name);
|
|
129
|
-
if (apiKeyEnv !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(apiKeyEnv)) throw failure('
|
|
129
|
+
if (apiKeyEnv !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(apiKeyEnv)) throw failure('Invalid credential environment variable.');
|
|
130
130
|
if (p.method !== 'device_code' || apiKeyEnv || suppliedSecret) {
|
|
131
|
-
if (p.id === 'codex') throw failure('Codex subscription
|
|
131
|
+
if (p.id === 'codex') throw failure('Codex subscription requires device login; use another profile for API keys.');
|
|
132
132
|
const variable = apiKeyEnv || p.apiKeyEnv, value = suppliedSecret || this.env[variable];
|
|
133
133
|
if (!value) return { ...await this.status(p.id), status: 'requires_secret', apiKeyEnv: variable,
|
|
134
|
-
instructions: `
|
|
135
|
-
if (!secret(value)) throw failure('
|
|
134
|
+
instructions: `Set ${variable} to ${p.subscription ? 'your subscription key' : 'your API key (API billing)'} and run login again.` };
|
|
135
|
+
if (!secret(value)) throw failure('Invalid credential.');
|
|
136
136
|
await this._locked(p.id, signal, () => this._write({ version: 1, provider: p.id, method: p.subscription ? 'subscription_key' : 'api_key', accessToken: value, updatedAt: this.now() }));
|
|
137
137
|
// A key has been stored, not checked by making a billable inference request.
|
|
138
138
|
return this.status(p.id);
|
|
@@ -141,13 +141,13 @@ export class AuthManager {
|
|
|
141
141
|
const codex = p.id === 'codex', host = codex ? 'https://auth.openai.com' : 'https://auth.kimi.com';
|
|
142
142
|
const initial = await this._request(host + (codex ? '/api/accounts/deviceauth/usercode' : '/api/oauth/device_authorization'),
|
|
143
143
|
{ signal, json: codex, values: { client_id: codex ? C : K } });
|
|
144
|
-
if (!initial.response.ok) throw failure(`
|
|
144
|
+
if (!initial.response.ok) throw failure(`Could not start ${p.id} login (HTTP ${initial.response.status}).`);
|
|
145
145
|
const d = initial.data, code = d.user_code || d.usercode;
|
|
146
|
-
if (!secret(code) || !secret(codex ? d.device_auth_id : d.device_code)) throw failure('
|
|
146
|
+
if (!secret(code) || !secret(codex ? d.device_auth_id : d.device_code)) throw failure('Invalid device code.');
|
|
147
147
|
const verification = codex ? host + '/codex/device' : d.verification_uri_complete || d.verification_uri;
|
|
148
148
|
let verificationURL;
|
|
149
|
-
try { verificationURL = new URL(verification); } catch { throw failure('
|
|
150
|
-
if (verificationURL.protocol !== 'https:' || verificationURL.username || verificationURL.password || !codex && !/^(?:[a-z0-9-]+\.)*kimi\.(?:com|ai)$/.test(verificationURL.hostname)) throw failure('
|
|
149
|
+
try { verificationURL = new URL(verification); } catch { throw failure('Invalid verification URL.'); }
|
|
150
|
+
if (verificationURL.protocol !== 'https:' || verificationURL.username || verificationURL.password || !codex && !/^(?:[a-z0-9-]+\.)*kimi\.(?:com|ai)$/.test(verificationURL.hostname)) throw failure('Invalid verification host.');
|
|
151
151
|
const expiresIn = Math.min(900, Math.max(1, Number(d.expires_in) || 900)), end = this.now() + expiresIn * 1000;
|
|
152
152
|
let interval = Math.min(60, Math.max(1, Number(d.interval) || 5)) * 1000;
|
|
153
153
|
await onEvent({ type: 'device_code', provider: p.id, verification_uri: verificationURL.href, user_code: code, expires_in: expiresIn });
|
|
@@ -159,26 +159,26 @@ export class AuthManager {
|
|
|
159
159
|
if (polled.response.ok) {
|
|
160
160
|
if (codex) {
|
|
161
161
|
const q = polled.data;
|
|
162
|
-
if (!secret(q.authorization_code) || !secret(q.code_verifier)) throw failure('
|
|
162
|
+
if (!secret(q.authorization_code) || !secret(q.code_verifier)) throw failure('Incomplete Codex authorization.');
|
|
163
163
|
const exchanged = await this._request(host + '/oauth/token', { signal, values: { client_id: C, grant_type: 'authorization_code', code: q.authorization_code, code_verifier: q.code_verifier, redirect_uri: host + '/deviceauth/callback' } });
|
|
164
|
-
if (!exchanged.response.ok) throw failure(`
|
|
164
|
+
if (!exchanged.response.ok) throw failure(`Codex token exchange rejected (HTTP ${exchanged.response.status}).`);
|
|
165
165
|
payload = exchanged.data;
|
|
166
166
|
} else payload = polled.data;
|
|
167
167
|
break;
|
|
168
168
|
}
|
|
169
169
|
const pending = codex ? [403, 404].includes(polled.response.status) : ['authorization_pending', 'slow_down'].includes(polled.data.error);
|
|
170
|
-
if (!pending) throw failure(
|
|
170
|
+
if (!pending) throw failure(`${p.id} login rejected or expired (HTTP ${polled.response.status}).`);
|
|
171
171
|
if (polled.data.error === 'slow_down') interval = Math.min(60000, interval + 5000);
|
|
172
172
|
await this.sleep(Math.min(interval, Math.max(0, end - this.now())), signal);
|
|
173
173
|
}
|
|
174
|
-
if (!payload) throw failure('
|
|
174
|
+
if (!payload) throw failure('The device code expired.', 'PROVIDER_AUTH_EXPIRED');
|
|
175
175
|
await this._locked(p.id, signal, () => this._write(tokenRecord(p.id, payload, this.now())));
|
|
176
176
|
await onEvent({ type: 'authenticated', provider: p.id });
|
|
177
177
|
return this.status(p.id);
|
|
178
178
|
}
|
|
179
179
|
async logout(name) {
|
|
180
180
|
const p = definition(name);
|
|
181
|
-
await this._locked(p.id, undefined, () => unlink(path.join(this.directory, `${p.id}.json`)).catch(error => { if (error.code !== 'ENOENT') throw failure('
|
|
181
|
+
await this._locked(p.id, undefined, () => unlink(path.join(this.directory, `${p.id}.json`)).catch(error => { if (error.code !== 'ENOENT') throw failure('Could not delete private credentials.'); }));
|
|
182
182
|
return { provider: p.id, status: 'logged_out', remoteRevoked: false };
|
|
183
183
|
}
|
|
184
184
|
async resolve(name, { signal, forceRefresh = false } = {}) {
|
|
@@ -188,14 +188,14 @@ export class AuthManager {
|
|
|
188
188
|
async _resolve(p, { signal, forceRefresh }) {
|
|
189
189
|
if (this.pendingRefresh.has(p.id)) await this.pendingRefresh.get(p.id);
|
|
190
190
|
let record = await this._read(p.id);
|
|
191
|
-
if (!record) throw failure(`
|
|
191
|
+
if (!record) throw failure(`Run /login ${p.id} first.`, 'PROVIDER_AUTH_REQUIRED');
|
|
192
192
|
if (record.method === 'device_code' && (forceRefresh || record.expiresAt <= this.now() + 60000)) {
|
|
193
|
-
if (!record.refreshToken) throw failure('
|
|
193
|
+
if (!record.refreshToken) throw failure('The login session expired; log in again.', 'PROVIDER_AUTH_EXPIRED');
|
|
194
194
|
const refresh = (async () => {
|
|
195
195
|
const codex = p.id === 'codex';
|
|
196
196
|
const { response, data } = await this._request(codex ? 'https://auth.openai.com/oauth/token' : 'https://auth.kimi.com/api/oauth/token', { signal,
|
|
197
197
|
values: { client_id: codex ? C : K, grant_type: 'refresh_token', refresh_token: record.refreshToken } });
|
|
198
|
-
if (!response.ok) throw failure(`
|
|
198
|
+
if (!response.ok) throw failure(`Could not refresh login (HTTP ${response.status}); log in again.`, 'PROVIDER_AUTH_EXPIRED');
|
|
199
199
|
record = tokenRecord(p.id, data, this.now(), record); await this._write(record);
|
|
200
200
|
})();
|
|
201
201
|
this.pendingRefresh.set(p.id, refresh);
|
|
@@ -13,40 +13,40 @@ const KEYS = new Set(['name', 'provider', 'protocol', 'model', 'baseURL', 'apiKe
|
|
|
13
13
|
const error = message => Object.assign(new Error(message), { code: 'PROVIDER_PROFILE_INVALID' });
|
|
14
14
|
|
|
15
15
|
export function normalizeCapabilities(value = {}, { protocol = 'openai' } = {}) {
|
|
16
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) throw error('capabilities
|
|
16
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw error('capabilities must be an object of booleans.');
|
|
17
17
|
const capabilities = { text: true, tools: true, vision: false, video: false, web_search: false };
|
|
18
18
|
for (const [key, enabled] of Object.entries(value)) {
|
|
19
|
-
if (!PROVIDER_CAPABILITIES.includes(key) || typeof enabled !== 'boolean') throw error(`
|
|
19
|
+
if (!PROVIDER_CAPABILITIES.includes(key) || typeof enabled !== 'boolean') throw error(`Invalid capability: ${key}.`);
|
|
20
20
|
capabilities[key] = enabled;
|
|
21
21
|
}
|
|
22
|
-
if (!capabilities.text) throw error('
|
|
23
|
-
if (protocol !== 'gemini' && (capabilities.video || capabilities.web_search)) throw error(`${protocol} no
|
|
22
|
+
if (!capabilities.text) throw error('Session profiles require text:true.');
|
|
23
|
+
if (protocol !== 'gemini' && (capabilities.video || capabilities.web_search)) throw error(`${protocol} has no native video/web_search adapter in Comal; use gemini or MCP tools.`);
|
|
24
24
|
return Object.freeze(capabilities);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export function normalizeProviderProfile(input, name = input?.name) {
|
|
28
|
-
if (!input || typeof input !== 'object' || Array.isArray(input)) throw error('
|
|
29
|
-
for (const key of Object.keys(input)) if (!KEYS.has(key)) throw error(`
|
|
30
|
-
if (typeof name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw error('
|
|
31
|
-
if (input.name && input.name !== name) throw error('
|
|
32
|
-
if (input.authProvider !== undefined && !['codex', 'kimi', 'minimax', 'grok'].includes(input.authProvider)) throw error('authProvider
|
|
28
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw error('Invalid profile.');
|
|
29
|
+
for (const key of Object.keys(input)) if (!KEYS.has(key)) throw error(`Field not allowed in profile: ${key}. Store only apiKeyEnv, never API keys.`);
|
|
30
|
+
if (typeof name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw error('Invalid profile name.');
|
|
31
|
+
if (input.name && input.name !== name) throw error('Conflicting profile name.');
|
|
32
|
+
if (input.authProvider !== undefined && !['codex', 'kimi', 'minimax', 'grok'].includes(input.authProvider)) throw error('Invalid authProvider.');
|
|
33
33
|
if (input.effort !== undefined) normalizeEffort(input.effort);
|
|
34
34
|
const toolSchema=input.toolSchema===undefined?undefined:normalizeToolSchemaCapabilities(input.toolSchema);
|
|
35
35
|
const access=input.authProvider?authProviderDefaults(input.authProvider):null;
|
|
36
36
|
const configuredCapabilities = input.capabilitiesConfigured || input.capabilities || {};
|
|
37
37
|
normalizeCapabilities(configuredCapabilities, { protocol: input.protocol || input.provider || access?.protocol || 'openai' });
|
|
38
38
|
const protocol = input.protocol || input.provider || access?.protocol || 'openai';
|
|
39
|
-
if(access && protocol!==access.protocol)throw error('
|
|
40
|
-
if (!PROTOCOLS.includes(protocol) || (input.protocol && input.provider && input.protocol !== input.provider)) throw error('
|
|
41
|
-
if (input.model !== undefined && (typeof input.model !== 'string' || !input.model.trim() || input.model.length > 256)) throw error('
|
|
42
|
-
if (protocol !== 'openai' && !input.model && !access?.model) throw error(`${protocol}
|
|
39
|
+
if(access && protocol!==access.protocol)throw error('The profile protocol does not match its login.');
|
|
40
|
+
if (!PROTOCOLS.includes(protocol) || (input.protocol && input.provider && input.protocol !== input.provider)) throw error('Invalid provider protocol.');
|
|
41
|
+
if (input.model !== undefined && (typeof input.model !== 'string' || !input.model.trim() || input.model.length > 256)) throw error('Invalid profile model.');
|
|
42
|
+
if (protocol !== 'openai' && !input.model && !access?.model) throw error(`${protocol} requires model in its profile.`);
|
|
43
43
|
if (input.baseURL !== undefined) {
|
|
44
|
-
if (typeof input.baseURL !== 'string') throw error('baseURL
|
|
44
|
+
if (typeof input.baseURL !== 'string') throw error('Invalid baseURL.');
|
|
45
45
|
const url = new URL(input.baseURL);
|
|
46
|
-
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw error('baseURL
|
|
46
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw error('baseURL requires HTTP(S), without credentials or a query.');
|
|
47
47
|
}
|
|
48
|
-
if (input.apiKeyEnv !== undefined && (typeof input.apiKeyEnv !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(input.apiKeyEnv))) throw error('apiKeyEnv
|
|
49
|
-
if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 1024)) throw error('description
|
|
48
|
+
if (input.apiKeyEnv !== undefined && (typeof input.apiKeyEnv !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(input.apiKeyEnv))) throw error('apiKeyEnv must name an environment variable.');
|
|
49
|
+
if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 1024)) throw error('Invalid description.');
|
|
50
50
|
return Object.freeze({ name, provider: protocol, protocol, ...((input.model || access?.model) ? { model: input.model || access.model } : {}),
|
|
51
51
|
baseURL: input.baseURL || access?.baseURL || ({ openai: DEFAULT_LOCAL_URL, anthropic: 'https://api.anthropic.com', gemini: 'https://generativelanguage.googleapis.com/v1beta' })[protocol],
|
|
52
52
|
apiKeyEnv: input.apiKeyEnv || ({ openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', gemini: 'GEMINI_API_KEY' })[protocol],
|
|
@@ -64,21 +64,21 @@ export async function loadProviderProfiles({ cwd = process.cwd(), configPath, en
|
|
|
64
64
|
if (failure.code === 'ENOENT' && !configPath && !env.COMAL_PROVIDERS_CONFIG) return { profiles: [], defaultProfile: null, configPath: file };
|
|
65
65
|
throw failure;
|
|
66
66
|
}
|
|
67
|
-
if (Buffer.byteLength(text) > 65536) throw error('
|
|
67
|
+
if (Buffer.byteLength(text) > 65536) throw error('Provider configuration exceeds 64 KiB.');
|
|
68
68
|
const config = JSON.parse(text);
|
|
69
|
-
if (!config || typeof config !== 'object' || Array.isArray(config) || Object.keys(config).some(key => !['profiles', 'defaultProfile', 'default', 'version'].includes(key))) throw error('
|
|
70
|
-
if (config.version !== undefined && config.version !== 1) throw error('
|
|
69
|
+
if (!config || typeof config !== 'object' || Array.isArray(config) || Object.keys(config).some(key => !['profiles', 'defaultProfile', 'default', 'version'].includes(key))) throw error('Invalid provider configuration format.');
|
|
70
|
+
if (config.version !== undefined && config.version !== 1) throw error('Unsupported provider configuration version.');
|
|
71
71
|
const raw = config.profiles;
|
|
72
|
-
if (!raw || typeof raw !== 'object') throw error('
|
|
72
|
+
if (!raw || typeof raw !== 'object') throw error('Configure profiles as an object or list.');
|
|
73
73
|
const entries = Array.isArray(raw) ? raw.map(input => [input?.name, input]) : Object.entries(raw);
|
|
74
|
-
if (entries.length > 32) throw error('
|
|
74
|
+
if (entries.length > 32) throw error('At most 32 provider profiles are allowed.');
|
|
75
75
|
const names = new Set(), profiles = entries.map(([name, input]) => {
|
|
76
76
|
const profile = normalizeProviderProfile(input, name);
|
|
77
|
-
if (names.has(profile.name)) throw error('
|
|
77
|
+
if (names.has(profile.name)) throw error('Duplicate profile name.');
|
|
78
78
|
names.add(profile.name); return profile;
|
|
79
79
|
});
|
|
80
80
|
const defaultProfile = config.defaultProfile ?? config.default ?? null;
|
|
81
|
-
if (defaultProfile !== null && (typeof defaultProfile !== 'string' || !names.has(defaultProfile))) throw error('
|
|
81
|
+
if (defaultProfile !== null && (typeof defaultProfile !== 'string' || !names.has(defaultProfile))) throw error('The default profile does not exist.');
|
|
82
82
|
return { profiles, defaultProfile, configPath: file };
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -86,7 +86,7 @@ export function selectProfile(registry, name) {
|
|
|
86
86
|
const requested = name || registry.defaultProfile;
|
|
87
87
|
if (!requested) return null;
|
|
88
88
|
const profile = registry.profiles.find(item => item.name === requested);
|
|
89
|
-
if (!profile) throw Object.assign(new Error(`
|
|
89
|
+
if (!profile) throw Object.assign(new Error(`Profile ${requested} does not exist.`), { code: 'PROVIDER_PROFILE_NOT_FOUND' });
|
|
90
90
|
return profile;
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -97,11 +97,11 @@ export function createProfileProvider(profile, { env = process.env, ...overrides
|
|
|
97
97
|
else if (overrides.vision === 'off') capabilities.vision = false;
|
|
98
98
|
const options = { ...overrides, apiKey: overrides.apiKey ?? env[profile.apiKeyEnv], model: overrides.model || profile.model, baseURL: overrides.baseURL || profile.baseURL,
|
|
99
99
|
capabilities, capabilitiesConfigured: profile.capabilitiesConfigured, effort: overrides.effort ?? profile.effort ?? 'auto', vision: overrides.vision || (Object.hasOwn(profile.capabilitiesConfigured, 'vision') ? capabilities.vision ? 'on' : 'off' : 'auto') };
|
|
100
|
-
if (profile.protocol !== 'openai' && !options.apiKey) throw new Error(`
|
|
100
|
+
if (profile.protocol !== 'openai' && !options.apiKey) throw new Error(`Set the profile apiKeyEnv variable ${profile.apiKeyEnv}.`);
|
|
101
101
|
const provider = profile.protocol === 'gemini' ? new GeminiProvider(options) : profile.protocol === 'anthropic' ? new AnthropicProvider(options) : new OpenAIProvider(options);
|
|
102
102
|
const complete = provider.complete.bind(provider);
|
|
103
103
|
provider.complete = request => {
|
|
104
|
-
if (!capabilities.tools && request.tools?.length) throw Object.assign(new Error('
|
|
104
|
+
if (!capabilities.tools && request.tools?.length) throw Object.assign(new Error('The profile does not enable tools.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
|
|
105
105
|
return complete(request);
|
|
106
106
|
};
|
|
107
107
|
provider.profileName = profile.name;
|
package/src/provider.mjs
CHANGED
|
@@ -25,7 +25,7 @@ export async function* sseEvents(body) {
|
|
|
25
25
|
};
|
|
26
26
|
for await (const chunk of body) {
|
|
27
27
|
total += chunk.byteLength;
|
|
28
|
-
if (total > 8 * 1024 * 1024) throw new Error('
|
|
28
|
+
if (total > 8 * 1024 * 1024) throw new Error('The model response exceeded 8 MiB.');
|
|
29
29
|
buffer += decoder.decode(chunk, { stream: true });
|
|
30
30
|
// Keep a trailing CR until the next chunk so split CRLF is normalized correctly.
|
|
31
31
|
buffer = buffer.replace(/\r\n/g, '\n');
|
|
@@ -40,27 +40,27 @@ export async function* sseEvents(body) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export async function analyzeImages(provider, { prompt, media, signal, maxTokens } = {}) {
|
|
43
|
-
if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 12000 || !Array.isArray(media) || !media.length || media.length > 4) throw new Error('
|
|
43
|
+
if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 12000 || !Array.isArray(media) || !media.length || media.length > 4) throw new Error('Invalid media request.');
|
|
44
44
|
const images = media.map(source => {
|
|
45
45
|
const mimeType = source.mimeType || source.media_type;
|
|
46
|
-
if (typeof mimeType !== 'string' || !/^image\/(?:png|jpeg|webp|gif)$/.test(mimeType)) throw Object.assign(new Error('
|
|
47
|
-
if (source.uri || source.file_uri || typeof source.data !== 'string' || !source.data || source.data.length % 4 || !/^[a-zA-Z0-9+/]*={0,2}$/.test(source.data)) throw new Error('
|
|
48
|
-
if (source.data.length > 4 * 1024 * 1024) throw new Error('
|
|
46
|
+
if (typeof mimeType !== 'string' || !/^image\/(?:png|jpeg|webp|gif)$/.test(mimeType)) throw Object.assign(new Error('This adapter supports images; video requires an enabled Gemini profile.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
|
|
47
|
+
if (source.uri || source.file_uri || typeof source.data !== 'string' || !source.data || source.data.length % 4 || !/^[a-zA-Z0-9+/]*={0,2}$/.test(source.data)) throw new Error('The image requires valid base64 data; this adapter does not accept remote URIs.');
|
|
48
|
+
if (source.data.length > 4 * 1024 * 1024) throw new Error('The image exceeds the 4 MiB encoded limit.');
|
|
49
49
|
return { type: 'image', source: { type: 'base64', media_type: mimeType, data: source.data } };
|
|
50
50
|
});
|
|
51
|
-
if (!(await provider.getVisionCapability({ signal })).supported) throw Object.assign(new Error('
|
|
51
|
+
if (!(await provider.getVisionCapability({ signal })).supported) throw Object.assign(new Error('The provider does not enable vision.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
|
|
52
52
|
const result = await provider.complete({ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }, ...images] }], tools: [], signal, maxTokens });
|
|
53
53
|
return { answer: result.content.filter(block => block.type === 'text').map(block => block.text).join(''), usage: result.usage, model: provider.model };
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function validateMessage(message) {
|
|
57
|
-
if (!message || !Array.isArray(message.content) || !message.stop_reason) throw new Error('
|
|
58
|
-
if(message.stop_reason==='max_tokens')throw Object.assign(new Error('
|
|
57
|
+
if (!message || !Array.isArray(message.content) || !message.stop_reason) throw new Error('Incomplete model response.');
|
|
58
|
+
if(message.stop_reason==='max_tokens')throw Object.assign(new Error('Anthropic response truncated; no tools were executed.'),{code:'OUTPUT_LENGTH',usage:message.usage,usage_accounting:normalizeUsage('anthropic',message.usage)});
|
|
59
59
|
const ids = new Set();
|
|
60
60
|
for (const block of message.content) {
|
|
61
|
-
if (!block || typeof block.type !== 'string') throw new Error('
|
|
61
|
+
if (!block || typeof block.type !== 'string') throw new Error('Invalid content block.');
|
|
62
62
|
if (block.type === 'tool_use') {
|
|
63
|
-
if (typeof block.id !== 'string' || !block.id || ids.has(block.id) || typeof block.name !== 'string' || !block.input || typeof block.input !== 'object' || Array.isArray(block.input)) throw new Error('
|
|
63
|
+
if (typeof block.id !== 'string' || !block.id || ids.has(block.id) || typeof block.name !== 'string' || !block.input || typeof block.input !== 'object' || Array.isArray(block.input)) throw new Error('Invalid tool call.');
|
|
64
64
|
ids.add(block.id);
|
|
65
65
|
}
|
|
66
66
|
}
|
|
@@ -69,10 +69,10 @@ function validateMessage(message) {
|
|
|
69
69
|
|
|
70
70
|
export class AnthropicProvider {
|
|
71
71
|
constructor({ apiKey = process.env.ANTHROPIC_API_KEY, baseURL = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com', model = process.env.COMAL_MODEL || process.env.HARNESS_MODEL || 'claude-sonnet-4-6', maxTokens = 4096, effort = 'auto', capabilitiesConfigured = {}, vision = 'auto', imageTokens = 4096, timeoutMs = 90000, fetchImpl = fetch } = {}) {
|
|
72
|
-
if (!apiKey) throw new Error('
|
|
72
|
+
if (!apiKey) throw new Error('Set ANTHROPIC_API_KEY or use --demo to try without credentials.');
|
|
73
73
|
const url = new URL(baseURL.replace(/\/$/, '') + '/v1/messages');
|
|
74
|
-
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw new Error('
|
|
75
|
-
if (url.username || url.password || url.search || url.hash) throw new Error('
|
|
74
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw new Error('The provider requires HTTPS; HTTP is allowed only on localhost.');
|
|
75
|
+
if (url.username || url.password || url.search || url.hash) throw new Error('Invalid provider URL.');
|
|
76
76
|
this.protocol = 'anthropic'; this.effort = normalizeEffort(effort); this.capabilitiesConfigured = { ...capabilitiesConfigured }; this.configuredCapabilities = this.capabilitiesConfigured;
|
|
77
77
|
this.apiKey = apiKey; this.url = url; this.model = model; this.maxTokens = maxTokens; this.timeoutMs = timeoutMs; this.fetch = fetchImpl;
|
|
78
78
|
Object.assign(this, visionSettings({ vision, imageTokens }, {}));
|
|
@@ -88,10 +88,10 @@ export class AnthropicProvider {
|
|
|
88
88
|
// but omit another provider's private reasoning from the Anthropic wire.
|
|
89
89
|
if (original.type === 'openai_reasoning' || original.type === 'thinking' && original.gemini_part) return [];
|
|
90
90
|
const { gemini_part: ignoredGeminiPart, ...block } = original;
|
|
91
|
-
if (block.type === 'video' || block.type === 'media') throw Object.assign(new Error('
|
|
92
|
-
if (block.type === 'image_reference') throw new Error('
|
|
91
|
+
if (block.type === 'video' || block.type === 'media') throw Object.assign(new Error('The Anthropic adapter does not support video; use a profile with media transport.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
|
|
92
|
+
if (block.type === 'image_reference') throw new Error('Unresolved image reference; it was not sent to the provider.');
|
|
93
93
|
if (block.type !== 'image') return block;
|
|
94
|
-
if (!this.getVisionCapability().supported) throw new Error('
|
|
94
|
+
if (!this.getVisionCapability().supported) throw new Error('Vision is not enabled for this provider; no pixels were sent.');
|
|
95
95
|
return { type: 'image', source: block.source };
|
|
96
96
|
}) }).filter(message => !Array.isArray(message.content) || message.content.length);
|
|
97
97
|
const requestSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(this.timeoutMs)]) : AbortSignal.timeout(this.timeoutMs);
|
|
@@ -99,7 +99,7 @@ export class AnthropicProvider {
|
|
|
99
99
|
const body = JSON.stringify({ ...effortWire, model: this.model, max_tokens: maxTokens, system, messages: mergeMessages(messages), ...(tools.length ? { tools } : {}), stream: true });
|
|
100
100
|
const account=result=>({...result,usage_accounting:bindUsageAccounting(normalizeUsage('anthropic',result.usage),accountingRequest,identity,body),schema_diagnostics:schemaFrame.diagnostics,schema_manifest:schemaFrame.manifest});
|
|
101
101
|
const finish=result=>{try{return account(validateMessage(result));}catch(error){if(error.usage_accounting)error.usage_accounting=bindUsageAccounting(error.usage_accounting,accountingRequest,identity,body);throw error;}};
|
|
102
|
-
if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw new Error('
|
|
102
|
+
if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw new Error('The context exceeds 8 MiB; start a new session with /clear.');
|
|
103
103
|
let response;
|
|
104
104
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
105
105
|
response = await this.fetch(this.url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': this.apiKey, 'anthropic-version': '2023-06-01' }, body, signal: requestSignal });
|
|
@@ -110,7 +110,7 @@ export class AnthropicProvider {
|
|
|
110
110
|
}
|
|
111
111
|
if (!response.ok) {
|
|
112
112
|
await response.body?.cancel();
|
|
113
|
-
throw new Error(`API HTTP ${response.status}.
|
|
113
|
+
throw new Error(`API HTTP ${response.status}. Check the key, model, limits or endpoint.`);
|
|
114
114
|
}
|
|
115
115
|
if (!(response.headers.get('content-type') || '').includes('text/event-stream')) {
|
|
116
116
|
const message = finish(await response.json());
|
|
@@ -120,29 +120,29 @@ export class AnthropicProvider {
|
|
|
120
120
|
let message, stopped = false;
|
|
121
121
|
const blocks = new Map(), partial = new Map(), closed = new Set();
|
|
122
122
|
for await (const event of sseEvents(response.body)) {
|
|
123
|
-
if (event.type === 'error') throw new Error(`
|
|
124
|
-
if (event.type === 'message_start') { if (message) throw new Error('message_start
|
|
123
|
+
if (event.type === 'error') throw new Error(`Streaming error: ${event.error?.type || 'unknown'}`);
|
|
124
|
+
if (event.type === 'message_start') { if (message) throw new Error('Duplicate message_start.'); message = structuredClone(event.message); }
|
|
125
125
|
else if (event.type === 'content_block_start') {
|
|
126
|
-
if (blocks.has(event.index)) throw new Error('
|
|
126
|
+
if (blocks.has(event.index)) throw new Error('Duplicate block.');
|
|
127
127
|
blocks.set(event.index, structuredClone(event.content_block));
|
|
128
128
|
} else if (event.type === 'content_block_delta') {
|
|
129
129
|
const block = blocks.get(event.index);
|
|
130
|
-
if (!block || closed.has(event.index)) throw new Error('Delta
|
|
130
|
+
if (!block || closed.has(event.index)) throw new Error('Delta outside its block.');
|
|
131
131
|
const d = event.delta;
|
|
132
132
|
if (d.type === 'text_delta') { block.text = (block.text || '') + d.text; onText(d.text); }
|
|
133
133
|
else if (d.type === 'input_json_delta') partial.set(event.index, (partial.get(event.index) || '') + d.partial_json);
|
|
134
134
|
else if (d.type === 'thinking_delta') block.thinking = (block.thinking || '') + d.thinking;
|
|
135
135
|
else if (d.type === 'signature_delta') block.signature = (block.signature || '') + d.signature;
|
|
136
136
|
} else if (event.type === 'content_block_stop') {
|
|
137
|
-
if (!blocks.has(event.index)) throw new Error('
|
|
137
|
+
if (!blocks.has(event.index)) throw new Error('Unknown block closure.');
|
|
138
138
|
if (partial.has(event.index)) blocks.get(event.index).input = JSON.parse(partial.get(event.index));
|
|
139
139
|
closed.add(event.index);
|
|
140
140
|
} else if (event.type === 'message_delta') {
|
|
141
|
-
if (!message) throw new Error('Delta
|
|
141
|
+
if (!message) throw new Error('Delta without a message.');
|
|
142
142
|
Object.assign(message, event.delta); message.usage = { ...message.usage, ...event.usage };
|
|
143
143
|
} else if (event.type === 'message_stop') stopped = true;
|
|
144
144
|
}
|
|
145
|
-
if (!stopped || !message || closed.size !== blocks.size) throw new Error('Streaming
|
|
145
|
+
if (!stopped || !message || closed.size !== blocks.size) throw new Error('Streaming interrupted; no tools from this response were executed.');
|
|
146
146
|
message.content = [...blocks.entries()].sort((a, b) => a[0] - b[0]).map(([, block]) => block);
|
|
147
147
|
return finish(message);
|
|
148
148
|
}
|
|
@@ -151,23 +151,23 @@ export class AnthropicProvider {
|
|
|
151
151
|
export class DemoProvider {
|
|
152
152
|
constructor(sessionId) { this.model = 'demo-scripted'; this.file = `comal-demo-${sessionId.slice(0, 8)}.md`; }
|
|
153
153
|
async complete({ messages, onText = () => {}, signal }) {
|
|
154
|
-
if (signal?.aborted) throw new Error('
|
|
154
|
+
if (signal?.aborted) throw new Error('Demo cancelled.');
|
|
155
155
|
const projection = messages.find(m => typeof m.content === 'string' && m.content.startsWith('Estado de trabajo derivado determinísticamente por Comal'));
|
|
156
156
|
const state = projection ? JSON.parse(projection.content.slice(projection.content.indexOf('\n') + 1)) : null;
|
|
157
157
|
const progress = state?.tool_progress.recent.filter(item => /^demo_\d+$/.test(item.id));
|
|
158
158
|
const calls = progress?.length ? Math.max(...progress.map(item => Number(item.id.slice(5)))) + 1 : messages.flatMap(m => Array.isArray(m.content) ? m.content : []).filter(b => b.type === 'tool_result').length;
|
|
159
159
|
const steps = [
|
|
160
|
-
['Write', { file_path: this.file, content: '# Comal\n\
|
|
160
|
+
['Write', { file_path: this.file, content: '# Comal\n\nTool cycle pending.\n' }],
|
|
161
161
|
['Read', { file_path: this.file }],
|
|
162
|
-
['Edit', { file_path: this.file, old_string: '
|
|
163
|
-
['Grep', { pattern: '
|
|
162
|
+
['Edit', { file_path: this.file, old_string: 'pending', new_string: 'verified' }],
|
|
163
|
+
['Grep', { pattern: 'verified', glob: this.file }]
|
|
164
164
|
];
|
|
165
165
|
if (calls < steps.length) {
|
|
166
166
|
const [name, input] = steps[calls];
|
|
167
167
|
return { role: 'assistant', content: [{ type: 'tool_use', id: `demo_${calls}`, name, input }], stop_reason: 'tool_use', usage: { input_tokens: 0, output_tokens: 0 } };
|
|
168
168
|
}
|
|
169
169
|
const failed = progress?.some(item => item.is_error) || messages.flatMap(m => Array.isArray(m.content) ? m.content : []).some(b => b.type === 'tool_result' && b.is_error);
|
|
170
|
-
const text = failed ? '
|
|
170
|
+
const text = failed ? 'The demo finished with denied tools or errors. Check the events; not all changes completed.' : `Demo without a real model: created, read, edited and searched text in ${this.file}. The history can be resumed.`;
|
|
171
171
|
onText(text);
|
|
172
172
|
return { role: 'assistant', content: [{ type: 'text', text }], stop_reason: 'end_turn', usage: { input_tokens: 0, output_tokens: 0 } };
|
|
173
173
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export const remoteCommandContract = JSON.parse(readFileSync(new URL('../backend/contracts/remote-commands-v1.json', import.meta.url), 'utf8'));
|
|
4
|
+
export function isRemoteCommand(text) {
|
|
5
|
+
const match = /^\/([a-z-]+)(?:[ \t]+([^\r\n]*))?$/.exec(text.trim());
|
|
6
|
+
if (!match) return false;
|
|
7
|
+
const command = remoteCommandContract.commands.find(item => item.name === match[1]);
|
|
8
|
+
const arg = (match[2] || '').trim();
|
|
9
|
+
return Boolean(command && (!arg || new RegExp('^(?:' + command.argument_pattern + ')$').test(arg)));
|
|
10
|
+
}
|
|
11
|
+
export const remoteCommandHelp = () => remoteCommandContract.commands.map(item => '/' + item.name + (item.arguments ? ' ' + item.arguments : '') + ' — ' + item.description).join('\n') + '\n\nPermission changes, credentials and session switching remain in the local terminal.';
|