aegis-desktop 0.7.6 → 0.7.8
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 +103 -17
- package/lib/local/endpoints.js +228 -0
- package/lib/local/engine.js +253 -7
- package/lib/settings.js +42 -1
- package/package.json +4 -3
- package/renderer/app.js +282 -118
- package/renderer/index.html +1 -0
- package/renderer/preset-fill.js +79 -0
- package/renderer/stream-policy.js +65 -1
- package/renderer/transcript-view.js +5 -0
- package/renderer/usage.js +42 -11
- package/vendor/aegis.js +21 -3
package/lib/local/engine.js
CHANGED
|
@@ -45,6 +45,10 @@ const os = require('node:os');
|
|
|
45
45
|
|
|
46
46
|
const toolsModule = require('./tools.js');
|
|
47
47
|
const promptModule = require('./prompt.js');
|
|
48
|
+
// The direct-dial policy: which base URLs may reach a provider transport, and
|
|
49
|
+
// why a remote one is refused rather than metered. The same module the CLI
|
|
50
|
+
// resolves (cli/src/custommodels.js), so both hosts answer with one rule.
|
|
51
|
+
const { isLocalEndpoint, remoteRefusal } = require('./endpoints.js');
|
|
48
52
|
const { ShellSession } = require('./shell.js');
|
|
49
53
|
const { agentSystemPrompt, agentRoleLabel } = require('./agents.js');
|
|
50
54
|
// Cooperative working-tree sharing (see each module's header). The lock
|
|
@@ -87,8 +91,28 @@ const CLASSES = [
|
|
|
87
91
|
{ class: 'ollama', label: 'Ollama (local)', kind: 'local' },
|
|
88
92
|
{ class: 'openai-compat', label: 'Custom OpenAI-compatible', kind: 'custom' },
|
|
89
93
|
{ class: 'anthropic', label: 'Anthropic-compatible', kind: 'custom' },
|
|
94
|
+
{ class: 'byok', label: 'Bring your own key', kind: 'cloud' },
|
|
90
95
|
];
|
|
91
96
|
|
|
97
|
+
/** Local settings namespace for one BYOK provider's key. A `byok:` prefix
|
|
98
|
+
* keeps this out of the 'anthropic'/'openai-compat' CUSTOM_CLASSES' own
|
|
99
|
+
* namespaces, which are a different feature (a self-hosted/compatible
|
|
100
|
+
* endpoint's base URL + key) — same store, deliberately separate rows. */
|
|
101
|
+
function byokNamespace(providerId) {
|
|
102
|
+
return `byok:${providerId}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Split a byok model id ("anthropic:claude-sonnet-5") into its provider and
|
|
106
|
+
* bare model parts. `modelId` may itself contain ':' (none of today's
|
|
107
|
+
* catalog ids do, but nothing guarantees that), so only the FIRST segment is
|
|
108
|
+
* the provider — the rest re-joins as the model. */
|
|
109
|
+
function splitByokModel(compound) {
|
|
110
|
+
const s = typeof compound === 'string' ? compound : '';
|
|
111
|
+
const i = s.indexOf(':');
|
|
112
|
+
if (i < 0) return { provider: '', model: s };
|
|
113
|
+
return { provider: s.slice(0, i), model: s.slice(i + 1) };
|
|
114
|
+
}
|
|
115
|
+
|
|
92
116
|
/**
|
|
93
117
|
* Mirrors aegiscodex-dev's src/backend.js DEEPSEEK_REASONING_MODEL_RE +
|
|
94
118
|
* EFFORT_TOKEN_BUDGET verbatim. DeepSeek's reasoning models (deepseek-flash,
|
|
@@ -637,14 +661,26 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
637
661
|
* a base URL is mandatory for both, and Anthropic additionally needs its own
|
|
638
662
|
* key (the wire format authenticates with x-api-key). Reporting them as
|
|
639
663
|
* always-ready made chat() POST to `${undefined}/v1/…` (defect #2).
|
|
664
|
+
*
|
|
665
|
+
* A stored REMOTE base URL is reported as not-configured rather than ready,
|
|
666
|
+
* plus `blocked` and the reason. This is the third face of the direct-dial
|
|
667
|
+
* gate (storage in settings.js set, dispatch in chat below): a row that
|
|
668
|
+
* predates the rule must not be OFFERED either, or the class list advertises
|
|
669
|
+
* a lane the turn then refuses. The reason travels so the UI can explain it
|
|
670
|
+
* instead of showing a dead row — and it is recoverable by design: saving a
|
|
671
|
+
* local URL (or clearing the field) makes the class usable again, which is
|
|
672
|
+
* what the refusal text tells the user to do.
|
|
640
673
|
*/
|
|
641
674
|
function customStatus(cls) {
|
|
642
675
|
const cfg = settings.get(cls) || {};
|
|
643
676
|
const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
|
|
644
677
|
const hasBase = Boolean(baseURL);
|
|
645
678
|
const hasKey = Boolean(cfg.configured);
|
|
679
|
+
const blocked = hasBase && !isLocalEndpoint(baseURL);
|
|
646
680
|
return {
|
|
647
|
-
configured: cls === 'anthropic' ? hasBase && hasKey : hasBase,
|
|
681
|
+
configured: !blocked && (cls === 'anthropic' ? hasBase && hasKey : hasBase),
|
|
682
|
+
blocked,
|
|
683
|
+
...(blocked ? { blockedReason: remoteRefusal(baseURL, { subject: `the ${cls} endpoint` }) } : {}),
|
|
648
684
|
baseURL,
|
|
649
685
|
keyMask: cfg.keyMask || null,
|
|
650
686
|
};
|
|
@@ -657,6 +693,16 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
657
693
|
if (c.class === 'aegis') {
|
|
658
694
|
return { ...c, configured: Boolean(aegis.apiKey) };
|
|
659
695
|
}
|
|
696
|
+
if (c.class === 'byok') {
|
|
697
|
+
// Configured means "at least one provider has a locally-stored key",
|
|
698
|
+
// not a single baseURL+key pair like the CUSTOM_CLASSES below — byok
|
|
699
|
+
// holds one row per provider (byokNamespace), so customStatus's shape
|
|
700
|
+
// does not apply here.
|
|
701
|
+
const anyConfigured = (settings.list() || []).some(
|
|
702
|
+
(s) => s && typeof s.provider === 'string' && s.provider.startsWith('byok:') && s.configured
|
|
703
|
+
);
|
|
704
|
+
return { ...c, configured: anyConfigured };
|
|
705
|
+
}
|
|
660
706
|
return { ...c, ...customStatus(c.class) };
|
|
661
707
|
});
|
|
662
708
|
}
|
|
@@ -681,6 +727,56 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
681
727
|
const tags = await ollama.listTags();
|
|
682
728
|
return { class: cls, models: tags.map((t) => ({ id: t.id })) };
|
|
683
729
|
}
|
|
730
|
+
if (cls === 'byok') {
|
|
731
|
+
// The server's catalog names every provider it accepts a key for, the
|
|
732
|
+
// models each unlocks, and whether an AEGIS account key is even needed
|
|
733
|
+
// to ask (it is not — the catalog answers an anonymous request).
|
|
734
|
+
// Deliberately NOT gated on aegis.apiKey the way the pooled class above
|
|
735
|
+
// is: BYOK's whole point is a caller who brings their own credential, and
|
|
736
|
+
// hiding the catalog would hide the answer to "which key do I go and get"
|
|
737
|
+
// from exactly the person deciding whether to bother. Nothing here
|
|
738
|
+
// enforces billing either: the desktop is one client of a route that is
|
|
739
|
+
// unauthenticated by design, so refusing in this process would stop
|
|
740
|
+
// exactly one host out of many. The relay owns that decision, and reports
|
|
741
|
+
// it via `fee.require_balance` below.
|
|
742
|
+
let providers = [];
|
|
743
|
+
let fee = null;
|
|
744
|
+
try {
|
|
745
|
+
const data = await aegis.byokProviders();
|
|
746
|
+
providers = (data && data.providers) || [];
|
|
747
|
+
// The handling fee AEGIS adds on top of the caller's vendor bill. It is
|
|
748
|
+
// the server's own published rate (services/pricing.price_byok_call) and
|
|
749
|
+
// is passed through untouched — never re-derived here, because a client
|
|
750
|
+
// that hardcodes a fee is a client that can disagree with the ledger.
|
|
751
|
+
// Absent until the server publishes one, and the UI must then say
|
|
752
|
+
// nothing rather than show a guess.
|
|
753
|
+
fee = (data && data.fee) || null;
|
|
754
|
+
} catch {
|
|
755
|
+
providers = [];
|
|
756
|
+
}
|
|
757
|
+
const models = [];
|
|
758
|
+
for (const p of providers) {
|
|
759
|
+
if (!p || !p.id) continue;
|
|
760
|
+
const local = settings.get(byokNamespace(p.id)) || {};
|
|
761
|
+
const rawModels = Array.isArray(p.models) ? p.models : [];
|
|
762
|
+
for (const m of rawModels) {
|
|
763
|
+
const modelId = typeof m === 'string' ? m : m && m.id;
|
|
764
|
+
if (!modelId) continue;
|
|
765
|
+
models.push({
|
|
766
|
+
id: `${p.id}:${modelId}`,
|
|
767
|
+
label: `${p.label || p.id} — ${modelId}`,
|
|
768
|
+
provider: p.id,
|
|
769
|
+
configured: Boolean(local.configured),
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return {
|
|
774
|
+
class: cls, models, providers,
|
|
775
|
+
needsProviderKey: models.length > 0 && !models.some((m) => m.configured),
|
|
776
|
+
needsAegisKey: !aegis.apiKey,
|
|
777
|
+
fee,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
684
780
|
// Custom endpoints: the model id is the *user's* choice — a provider model
|
|
685
781
|
// name, never a URL. Offering the configured base URL as an `id` meant that
|
|
686
782
|
// leaving the default selection POSTed `model: "https://api.openai.com/v1"`,
|
|
@@ -690,7 +786,18 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
690
786
|
// along for display only.
|
|
691
787
|
const cfg = settings.get(cls) || {};
|
|
692
788
|
const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
|
|
693
|
-
|
|
789
|
+
// Same reporting as customStatus: a stored remote URL is not a usable
|
|
790
|
+
// model list, and the reason has to reach the UI with it (see customStatus).
|
|
791
|
+
const blocked = Boolean(baseURL) && !isLocalEndpoint(baseURL);
|
|
792
|
+
return {
|
|
793
|
+
class: cls,
|
|
794
|
+
models: [],
|
|
795
|
+
needsModelId: true,
|
|
796
|
+
baseURL,
|
|
797
|
+
...(blocked
|
|
798
|
+
? { blocked: true, blockedReason: remoteRefusal(baseURL, { subject: `the ${cls} endpoint` }) }
|
|
799
|
+
: {}),
|
|
800
|
+
};
|
|
694
801
|
}
|
|
695
802
|
|
|
696
803
|
/**
|
|
@@ -867,6 +974,53 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
867
974
|
});
|
|
868
975
|
}
|
|
869
976
|
|
|
977
|
+
if (cls === 'byok') {
|
|
978
|
+
// opts.model is still the compound "provider:model" id here — the
|
|
979
|
+
// relay wants them split (a `provider` field plus a bare `model`).
|
|
980
|
+
// opts.apiKey is the PROVIDER key resolved by chat() above (from the
|
|
981
|
+
// byokNamespace(provider) settings row), never the account's own AEGIS
|
|
982
|
+
// key — that one is baked into the `aegis` client already and attached
|
|
983
|
+
// automatically by byokChatCompletion() as X-AEGIS-Key so the account
|
|
984
|
+
// gets billed the handling fee; see client/aegis.js.
|
|
985
|
+
const { provider, model: bareModel } = splitByokModel(opts.model);
|
|
986
|
+
try {
|
|
987
|
+
return await aegis.byokChatCompletion({
|
|
988
|
+
provider,
|
|
989
|
+
model: bareModel,
|
|
990
|
+
providerKey: opts.apiKey,
|
|
991
|
+
prompt: opts.prompt,
|
|
992
|
+
system: opts.system,
|
|
993
|
+
messages: opts.messages,
|
|
994
|
+
maxTokens: opts.maxTokens,
|
|
995
|
+
stream: true,
|
|
996
|
+
onStream: opts.onDelta,
|
|
997
|
+
signal: opts.signal,
|
|
998
|
+
});
|
|
999
|
+
} catch (e) {
|
|
1000
|
+
// The relay's own balance gate (aegis1 `AEGIS_BYOK_REQUIRE_BALANCE`,
|
|
1001
|
+
// app.py `byok_chat_completions`) answers 402 with a body aimed at an
|
|
1002
|
+
// API consumer — "Insufficient balance. Top up to continue using
|
|
1003
|
+
// BYOK." Shown raw in a chat transcript that reads as a crash rather
|
|
1004
|
+
// than a bill, so the one thing the user has to DO is said here, in the
|
|
1005
|
+
// hosts' own voice. The status is preserved so every existing caller
|
|
1006
|
+
// (the CLI's error painter, the desktop turn guard) still sees a 402.
|
|
1007
|
+
//
|
|
1008
|
+
// The provider key is untouched by this: it is the caller's own and it
|
|
1009
|
+
// is still valid. What ran out is the AEGIS balance the handling fee
|
|
1010
|
+
// is billed against, which is the whole reason this lane has a fee.
|
|
1011
|
+
if (e && e.status === 402) {
|
|
1012
|
+
const err = new Error(
|
|
1013
|
+
'byok: this account has no AEGIS balance left, and the BYOK handling fee is ' +
|
|
1014
|
+
'billed there — your provider key is still valid, this is not a key problem. ' +
|
|
1015
|
+
'Top up the account, then try again (or /class aegis to use the pooled lane).'
|
|
1016
|
+
);
|
|
1017
|
+
err.status = 402;
|
|
1018
|
+
throw err;
|
|
1019
|
+
}
|
|
1020
|
+
throw e;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
870
1024
|
const common = {
|
|
871
1025
|
baseURL: opts.cfg.baseURL,
|
|
872
1026
|
apiKey: opts.apiKey,
|
|
@@ -887,7 +1041,14 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
887
1041
|
async function chat(payload, onDelta) {
|
|
888
1042
|
const cls = payload && payload.class;
|
|
889
1043
|
const model = payload && payload.model;
|
|
890
|
-
|
|
1044
|
+
// byok's model id is "provider:model" (see splitByokModel) — strip the
|
|
1045
|
+
// provider prefix before pattern-matching, or a DeepSeek reasoning model
|
|
1046
|
+
// selected under byok ("byok" model "deepseek:deepseek-v4-flash") never
|
|
1047
|
+
// matches DEEPSEEK_REASONING_MODEL_RE's anchored pattern and silently
|
|
1048
|
+
// gets no stated budget, which is exactly the "hidden CoT ate the whole
|
|
1049
|
+
// default and returned nothing" failure this budget exists to prevent.
|
|
1050
|
+
const reasoningModelId = cls === 'byok' ? splitByokModel(model).model : model;
|
|
1051
|
+
const maxTokens = reasoningBudget(cls, reasoningModelId, payload && payload.maxTokens, payload && payload.effort);
|
|
891
1052
|
// The caller's OWN number, kept apart from `maxTokens` above. That one
|
|
892
1053
|
// collapses two different facts into a single value — "the caller stated
|
|
893
1054
|
// 4096" and "effort implies 32768" — and the pooled path must treat them
|
|
@@ -937,8 +1098,12 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
937
1098
|
const signal = controller.signal;
|
|
938
1099
|
|
|
939
1100
|
// A caller can opt out of the agent loop entirely (`tools: false`) and get
|
|
940
|
-
// the old single-shot turn back.
|
|
941
|
-
|
|
1101
|
+
// the old single-shot turn back. byok is ALWAYS single-shot: the
|
|
1102
|
+
// stateless relay (aegis.byokChatCompletion, /api/v1/byok/chat/completions)
|
|
1103
|
+
// has no tools/tool_choice parameter at all, so sending schemas here would
|
|
1104
|
+
// build a prompt promising tool access the transport silently drops —
|
|
1105
|
+
// the model would reason about exec/readFile and never see a result.
|
|
1106
|
+
const toolsEnabled = cls !== 'byok' && !(payload && payload.tools === false);
|
|
942
1107
|
const wire = cls === 'anthropic' ? 'anthropic' : 'openai';
|
|
943
1108
|
const toolSchemas = toolsEnabled ? T.toolsFor(wire, { includeSubagent: depth < MAX_SUBAGENT_DEPTH }) : [];
|
|
944
1109
|
const toolChoice = (payload && payload.toolChoice) || null;
|
|
@@ -965,8 +1130,89 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
965
1130
|
const turnCwd = envFor(payload).cwd;
|
|
966
1131
|
|
|
967
1132
|
try {
|
|
968
|
-
|
|
969
|
-
|
|
1133
|
+
// byok's settings row lives under the provider named IN THE MODEL id
|
|
1134
|
+
// ("anthropic:claude-sonnet-5" -> byokNamespace('anthropic')), never
|
|
1135
|
+
// under the literal class name — one flat 'byok' row could not hold
|
|
1136
|
+
// more than one provider's key at a time.
|
|
1137
|
+
const byokParts = cls === 'byok' ? splitByokModel(model) : null;
|
|
1138
|
+
const cfg =
|
|
1139
|
+
cls === 'aegis' || cls === 'ollama' ? {}
|
|
1140
|
+
: cls === 'byok' ? settings.get(byokNamespace(byokParts.provider)) || {}
|
|
1141
|
+
: settings.get(cls) || {};
|
|
1142
|
+
const apiKey =
|
|
1143
|
+
cls === 'aegis' || cls === 'ollama' ? null
|
|
1144
|
+
: cls === 'byok' ? settings.rawKey(byokNamespace(byokParts.provider))
|
|
1145
|
+
: settings.rawKey(cls);
|
|
1146
|
+
|
|
1147
|
+
if (cls === 'byok' && (!byokParts.provider || !byokParts.model)) {
|
|
1148
|
+
const err = new Error(
|
|
1149
|
+
"byok: model must be \"<provider>:<model>\" (pick one from /models) — got " +
|
|
1150
|
+
JSON.stringify(model || '')
|
|
1151
|
+
);
|
|
1152
|
+
err.status = 400;
|
|
1153
|
+
throw err;
|
|
1154
|
+
}
|
|
1155
|
+
if (cls === 'byok' && !apiKey) {
|
|
1156
|
+
const err = new Error(
|
|
1157
|
+
`byok: no key saved for "${byokParts.provider}" — add one before chatting with this model.`
|
|
1158
|
+
);
|
|
1159
|
+
err.status = 400;
|
|
1160
|
+
throw err;
|
|
1161
|
+
}
|
|
1162
|
+
// …and the AEGIS account key is what makes the turn BILLABLE at all. The
|
|
1163
|
+
// relay authenticates on the provider key and resolves the payer
|
|
1164
|
+
// separately, from X-AEGIS-Key (app.py `_byok_identify_user`): with no
|
|
1165
|
+
// account key the server can attribute the handling fee to no one, so it
|
|
1166
|
+
// is logged against user 0 as uncollected — an anonymous free ride the
|
|
1167
|
+
// fee exists to close. Require the account key here so EVERY BYOK turn is
|
|
1168
|
+
// attributed and every caller pays the handling fee: a funded balance is
|
|
1169
|
+
// debited immediately, an unfunded one records the fee as owed
|
|
1170
|
+
// (token_bank.charge_byok clamps to zero and never refuses), and nobody
|
|
1171
|
+
// is served free. Balance is the server's own concern, not this gate's —
|
|
1172
|
+
// with a balance or without one, the account still pays. `listModels`
|
|
1173
|
+
// still answers anonymously (the catalog is how a user finds out which
|
|
1174
|
+
// key to get), so this refuses only the send, and only until a key lands.
|
|
1175
|
+
if (cls === 'byok' && !aegis.apiKey) {
|
|
1176
|
+
const err = new Error(
|
|
1177
|
+
'byok: connect your AEGIS account key first — the BYOK handling fee is billed there. ' +
|
|
1178
|
+
'Add it in the Status card (or run /login), then try again.'
|
|
1179
|
+
);
|
|
1180
|
+
err.status = 401;
|
|
1181
|
+
throw err;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// THE DIRECT-DIAL GATE. A custom class talks to the user's base URL
|
|
1185
|
+
// itself (providers.js openaiCompatible / anthropicMessages), and that
|
|
1186
|
+
// transport bills NOBODY: no pooled margin, no BYOK handling fee, no
|
|
1187
|
+
// account key attached. The only usage it may therefore carry is an
|
|
1188
|
+
// endpoint on this machine, where there is no vendor to pay. A remote URL
|
|
1189
|
+
// here is an unpaid turn, and it is refused rather than metered because
|
|
1190
|
+
// there is nothing to meter it against — the relay accepts a fixed
|
|
1191
|
+
// catalog of provider ids (services/nexus_provider/catalog.py), so an
|
|
1192
|
+
// arbitrary remote URL cannot be billed there either.
|
|
1193
|
+
//
|
|
1194
|
+
// Checked at DISPATCH and not only at storage (settings.js set refuses
|
|
1195
|
+
// the same URL): a row written before this rule existed, or hand-edited
|
|
1196
|
+
// into settings.json, is still in the file, and reading it back happily
|
|
1197
|
+
// would keep the lane open. Both seams, one rule — the message is
|
|
1198
|
+
// endpoints.js's, so the desktop and the CLI say the same thing.
|
|
1199
|
+
if (CUSTOM_CLASSES.includes(cls)) {
|
|
1200
|
+
const raw = cfg && typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
|
|
1201
|
+
if (raw && !isLocalEndpoint(raw)) {
|
|
1202
|
+
const err = new Error(
|
|
1203
|
+
remoteRefusal(raw, {
|
|
1204
|
+
subject: `the ${cls} endpoint`,
|
|
1205
|
+
hint:
|
|
1206
|
+
'This class dials your URL directly and bills nobody, so it is local-only. ' +
|
|
1207
|
+
'Use a model on this machine, or move the provider to the BYOK card (/class byok), ' +
|
|
1208
|
+
'which relays through AEGIS and charges the handling fee.',
|
|
1209
|
+
})
|
|
1210
|
+
);
|
|
1211
|
+
err.status = 400;
|
|
1212
|
+
err.code = 'CUSTOM_ENDPOINT_NOT_LOCAL';
|
|
1213
|
+
throw err;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
970
1216
|
|
|
971
1217
|
// Custom classes carry no enumerable model list (see listModels), so a
|
|
972
1218
|
// blank id here means the user never typed one. Fail loudly in-process
|
package/lib/settings.js
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
const fs = require('node:fs');
|
|
19
19
|
const path = require('node:path');
|
|
20
|
+
// The direct-dial policy (see that file): which rows may hold a remote base URL.
|
|
21
|
+
// Enforced HERE, at the storage seam, so the unusable configuration cannot be
|
|
22
|
+
// created by either host — the desktop's Settings pane or the CLI's `/model add`
|
|
23
|
+
// — rather than only being refused at send time. Pure node builtins, so this
|
|
24
|
+
// store stays loadable without Electron.
|
|
25
|
+
const { isLocalEndpoint, remoteRefusal, isDirectDialRow } = require('./local/endpoints.js');
|
|
20
26
|
|
|
21
27
|
const SETTINGS_FILE = 'settings.json';
|
|
22
28
|
|
|
@@ -124,8 +130,13 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
124
130
|
function save(data) {
|
|
125
131
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
126
132
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
127
|
-
|
|
133
|
+
// 0600, written before the rename so the file is never briefly readable:
|
|
134
|
+
// this store holds provider keys, and both hosts are now able to write it
|
|
135
|
+
// (the CLI uses the same file, without Electron's safeStorage, so at-rest
|
|
136
|
+
// base64 is all the protection there is — file mode is the real control).
|
|
137
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
128
138
|
fs.renameSync(tmp, file);
|
|
139
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
/** Provider CRUD must never touch a reserved (AEGIS-key) namespace. */
|
|
@@ -149,8 +160,38 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
149
160
|
};
|
|
150
161
|
}
|
|
151
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Write a provider row.
|
|
165
|
+
*
|
|
166
|
+
* A direct-dial row ('openai-compat', 'anthropic', 'custom:*') may not hold a
|
|
167
|
+
* remote base URL: those classes talk to the URL themselves (providers.js),
|
|
168
|
+
* which bills nobody, so the only usage they may carry is an endpoint on this
|
|
169
|
+
* machine. Refusing at STORAGE — the earliest seam, shared by the desktop's
|
|
170
|
+
* Settings pane and the CLI — means a remote custom endpoint cannot be
|
|
171
|
+
* configured into existence in the first place; the engine's dispatch gate
|
|
172
|
+
* then covers the rows that predate this rule or were hand-edited into the
|
|
173
|
+
* file. Empty is allowed (that is how the row is cleared, and how a
|
|
174
|
+
* not-yet-configured row looks), and every other namespace is untouched:
|
|
175
|
+
* `byok:<provider>` rows are remote by definition and reach their provider
|
|
176
|
+
* through AEGIS's relay, which is what bills them.
|
|
177
|
+
*/
|
|
152
178
|
function set(provider, { baseURL, key } = {}) {
|
|
153
179
|
assertNotReserved(provider);
|
|
180
|
+
if (baseURL !== undefined && isDirectDialRow(provider)) {
|
|
181
|
+
const next = String(baseURL == null ? '' : baseURL).trim();
|
|
182
|
+
if (next && !isLocalEndpoint(next)) {
|
|
183
|
+
const err = new Error(remoteRefusal(next, {
|
|
184
|
+
subject: `the ${provider} endpoint`,
|
|
185
|
+
hint:
|
|
186
|
+
'This lane is for a model running on this machine. To use a remote provider, ' +
|
|
187
|
+
'save it under Bring-your-own-key (the CLI\'s /class byok), where the AEGIS handling ' +
|
|
188
|
+
'fee bills the turn.',
|
|
189
|
+
}));
|
|
190
|
+
err.status = 400;
|
|
191
|
+
err.code = 'CUSTOM_ENDPOINT_NOT_LOCAL';
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
154
195
|
const data = load();
|
|
155
196
|
const cfg = data[provider] || {};
|
|
156
197
|
if (baseURL !== undefined) cfg.baseURL = baseURL;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-desktop",
|
|
3
3
|
"productName": "AEGIS Desktop",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.8",
|
|
5
5
|
"description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "AEGIS Code",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"icon": "node scripts/generate-icon.mjs",
|
|
30
30
|
"dist": "npm run predist && electron-builder",
|
|
31
31
|
"dist:dir": "npm run predist && electron-builder --dir",
|
|
32
|
-
"check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/budget.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/diff.js && node --check renderer/diffview.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/session-rounds.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/local/queue.js && node --check lib/local/autonomous.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
|
|
32
|
+
"check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/budget.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/preset-fill.js && node --check renderer/diff.js && node --check renderer/diffview.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/endpoints.js && node --check lib/local/session-rounds.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/local/queue.js && node --check lib/local/autonomous.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
|
|
33
33
|
"test:shell": "node ../test/desktop-shell.mjs",
|
|
34
34
|
"test:model": "node ../test/model-dispatch.mjs",
|
|
35
35
|
"test:budget": "node ../test/budget.test.mjs",
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"test:preview-read": "node ../test/desktop-preview-read.test.mjs",
|
|
44
44
|
"test:stream-policy": "node ../test/stream-policy.test.mjs",
|
|
45
45
|
"test:renderer-dom": "node ../test/renderer-dom.test.mjs",
|
|
46
|
-
"test:renderer-wiring": "node ../test/renderer-wiring.test.mjs"
|
|
46
|
+
"test:renderer-wiring": "node ../test/renderer-wiring.test.mjs",
|
|
47
|
+
"test:preset-fill": "node ../test/preset-fill.test.mjs"
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"electron": "^33.0.0",
|