quoxcli 0.2.0 → 0.3.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.
- package/LICENSE +1 -1
- package/README.md +2 -0
- package/bin/quox.js +99 -126
- package/lib/api/endpoints/admin.js +26 -0
- package/lib/api/endpoints/agent-schedules.js +26 -0
- package/lib/api/endpoints/agents.js +7 -0
- package/lib/api/endpoints/assistants.js +12 -0
- package/lib/api/endpoints/away.js +15 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/contactsq.js +2 -0
- package/lib/api/endpoints/conversations.js +14 -0
- package/lib/api/endpoints/executions.js +13 -0
- package/lib/api/endpoints/fetchAllowlist.js +27 -0
- package/lib/api/endpoints/files.js +14 -0
- package/lib/api/endpoints/hat.js +30 -0
- package/lib/api/endpoints/inbox.js +20 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/memory.js +19 -0
- package/lib/api/endpoints/mind.js +13 -0
- package/lib/api/endpoints/mon.js +6 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/plugins.js +18 -0
- package/lib/api/endpoints/presence.js +33 -0
- package/lib/api/endpoints/quoxplan.js +7 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/router.js +17 -0
- package/lib/api/endpoints/safety.js +7 -0
- package/lib/api/endpoints/skills.js +20 -0
- package/lib/api/endpoints/stepup.js +20 -0
- package/lib/api/endpoints/teams.js +2 -2
- package/lib/api/endpoints/triggers.js +26 -0
- package/lib/api/endpoints/v1.js +86 -0
- package/lib/api/endpoints/ward.js +7 -0
- package/lib/api/endpoints/workflows.js +7 -0
- package/lib/api/endpoints/x.js +8 -0
- package/lib/commands/actions.js +61 -0
- package/lib/commands/admin.js +16 -0
- package/lib/commands/agent.js +163 -27
- package/lib/commands/agentAttach.js +94 -6
- package/lib/commands/approvals.js +73 -1
- package/lib/commands/aside.js +24 -0
- package/lib/commands/ask.js +144 -0
- package/lib/commands/assistant.js +59 -4
- package/lib/commands/auth.js +8 -2
- package/lib/commands/backup.js +8 -4
- package/lib/commands/brain.js +105 -14
- package/lib/commands/build.js +19 -5
- package/lib/commands/chat.js +57 -2
- package/lib/commands/claude.js +34 -8
- package/lib/commands/contacts.js +49 -0
- package/lib/commands/conversation.js +35 -4
- package/lib/commands/dashboard.js +43 -0
- package/lib/commands/department.js +28 -7
- package/lib/commands/devResident.js +15 -3
- package/lib/commands/devSupervisor.js +13 -1
- package/lib/commands/discord.js +75 -39
- package/lib/commands/done.js +98 -28
- package/lib/commands/engagement.js +17 -6
- package/lib/commands/fetchAllowlist.js +71 -0
- package/lib/commands/file.js +38 -1
- package/lib/commands/fleet.js +311 -4
- package/lib/commands/governance.js +12 -3
- package/lib/commands/gripe.js +44 -13
- package/lib/commands/hat.js +178 -9
- package/lib/commands/helpers.js +18 -1
- package/lib/commands/hyperion.js +58 -0
- package/lib/commands/inbox.js +74 -23
- package/lib/commands/licence.js +14 -155
- package/lib/commands/lucid.js +10 -4
- package/lib/commands/memory.js +85 -8
- package/lib/commands/mind.js +71 -0
- package/lib/commands/mon.js +69 -2
- package/lib/commands/monitoring.js +1 -1
- package/lib/commands/motion.js +2 -2
- package/lib/commands/nucleus.js +48 -17
- package/lib/commands/objective.js +11 -0
- package/lib/commands/offload.js +89 -0
- package/lib/commands/onboard.js +2 -1
- package/lib/commands/org.js +11 -1
- package/lib/commands/peek.js +28 -0
- package/lib/commands/plan.js +38 -0
- package/lib/commands/plugin.js +110 -0
- package/lib/commands/presence.js +89 -0
- package/lib/commands/provider.js +159 -0
- package/lib/commands/qlarity.js +19 -3
- package/lib/commands/quoxchat.js +13 -4
- package/lib/commands/quoxseo.js +38 -0
- package/lib/commands/reach.js +2 -1
- package/lib/commands/router.js +77 -0
- package/lib/commands/rows.js +358 -0
- package/lib/commands/run.js +64 -9
- package/lib/commands/server.js +292 -0
- package/lib/commands/setup.js +33 -7
- package/lib/commands/skills.js +48 -5
- package/lib/commands/slot.js +260 -7
- package/lib/commands/status.js +1 -1
- package/lib/commands/team.js +3 -1
- package/lib/commands/tips.js +23 -0
- package/lib/commands/tool.js +133 -45
- package/lib/commands/trace.js +12 -7
- package/lib/commands/trigger.js +95 -0
- package/lib/commands/update.js +46 -0
- package/lib/commands/vault.js +31 -8
- package/lib/commands/vm.js +237 -0
- package/lib/commands/voice.js +34 -3
- package/lib/commands/volt.js +45 -3
- package/lib/commands/walkthrough.js +32 -0
- package/lib/commands/watch.js +73 -21
- package/lib/commands/welcome.js +14 -0
- package/lib/commands/workflow.js +189 -32
- package/lib/commands/x.js +13 -4
- package/lib/core/actingAs.js +2 -2
- package/lib/core/activeSummary.js +186 -0
- package/lib/core/aside.js +54 -0
- package/lib/core/askDecision.js +60 -0
- package/lib/core/auth.js +164 -13
- package/lib/core/callerSource.js +1 -1
- package/lib/core/cliBuild.js +62 -0
- package/lib/core/client.js +144 -35
- package/lib/core/commandRegistrars.js +121 -0
- package/lib/core/config.js +142 -24
- package/lib/core/customHats.js +168 -0
- package/lib/core/doneEngineFetch.js +8 -0
- package/lib/core/errors.js +329 -20
- package/lib/core/global-flags.js +105 -5
- package/lib/core/hyperion.js +102 -0
- package/lib/core/inboxResolution.js +65 -0
- package/lib/core/lazyCommands.js +4 -1
- package/lib/core/offload.js +504 -0
- package/lib/core/org-override.js +1 -1
- package/lib/core/orgResolve.js +70 -0
- package/lib/core/output.js +4 -4
- package/lib/core/peek.js +126 -0
- package/lib/core/profileUsers.js +51 -0
- package/lib/core/promptCancel.js +18 -0
- package/lib/core/repoPath.js +11 -0
- package/lib/core/residentSeed.js +3 -1
- package/lib/core/rootOptions.js +39 -0
- package/lib/core/router.js +168 -0
- package/lib/core/selfUpdate.js +188 -0
- package/lib/core/sessionsOverview.js +64 -0
- package/lib/core/slotKeeper.js +248 -3
- package/lib/core/slotWake.js +120 -0
- package/lib/core/supervisorEngine.js +71 -2
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +361 -0
- package/lib/core/vmTui.js +86 -0
- package/lib/core/walkthrough.js +35 -0
- package/lib/hyperion/deck.js +29 -0
- package/lib/lucid/LucidCard.jsx +68 -0
- package/lib/lucid/LucidIntro.jsx +45 -58
- package/lib/lucid/cards.js +86 -0
- package/lib/lucid/markdown.js +81 -0
- package/lib/lucid/motion.js +56 -0
- package/lib/quoxon/activity.js +14 -2
- package/lib/quoxon/aocl.js +117 -0
- package/lib/quoxon/api.js +504 -25
- package/lib/quoxon/app.js +3000 -230
- package/lib/quoxon/approvalFacts.js +94 -0
- package/lib/quoxon/approvalsView.js +325 -0
- package/lib/quoxon/asideView.js +40 -0
- package/lib/quoxon/ask.js +121 -0
- package/lib/quoxon/away.js +52 -0
- package/lib/quoxon/bang.js +224 -0
- package/lib/quoxon/bulkRisk.js +17 -0
- package/lib/quoxon/capabilities.js +13 -6
- package/lib/quoxon/cells.js +53 -0
- package/lib/quoxon/commandShort.js +112 -0
- package/lib/quoxon/commands.js +363 -38
- package/lib/quoxon/connect.js +210 -23
- package/lib/quoxon/connectCard.js +19 -2
- package/lib/quoxon/consent.js +86 -0
- package/lib/quoxon/copy.js +4 -3
- package/lib/quoxon/countdownLine.js +11 -0
- package/lib/quoxon/demo.autoplay.js +29 -0
- package/lib/quoxon/demo.fixtures.js +1 -1
- package/lib/quoxon/demo.js +44 -3
- package/lib/quoxon/devObserver.js +20 -0
- package/lib/quoxon/dividerAocl.js +191 -0
- package/lib/quoxon/dividerRule.js +57 -0
- package/lib/quoxon/dragonModes.js +150 -0
- package/lib/quoxon/dragonView.js +41 -0
- package/lib/quoxon/earlyInput.js +40 -0
- package/lib/quoxon/echo.js +15 -0
- package/lib/quoxon/egg.js +2 -2
- package/lib/quoxon/events.js +244 -25
- package/lib/quoxon/feedback.js +50 -0
- package/lib/quoxon/gallery.js +21 -7
- package/lib/quoxon/handoffOffer.js +78 -0
- package/lib/quoxon/hatSwitch.js +37 -0
- package/lib/quoxon/headless.js +161 -0
- package/lib/quoxon/hostUrls.js +41 -0
- package/lib/quoxon/indicator.js +6 -2
- package/lib/quoxon/intent.js +12 -1
- package/lib/quoxon/keys.js +49 -6
- package/lib/quoxon/launcher.js +103 -0
- package/lib/quoxon/main.js +92 -160
- package/lib/quoxon/markdown.js +45 -21
- package/lib/quoxon/markdownSettle.js +19 -0
- package/lib/quoxon/matrixDomains.json +46 -0
- package/lib/quoxon/mentions.js +90 -0
- package/lib/quoxon/mind.js +103 -0
- package/lib/quoxon/mode.js +46 -0
- package/lib/quoxon/model.js +132 -26
- package/lib/quoxon/modelPrefs.js +345 -0
- package/lib/quoxon/motion/clock.js +3 -2
- package/lib/quoxon/motion/level.js +91 -0
- package/lib/quoxon/mouse.js +211 -0
- package/lib/quoxon/noai.js +57 -0
- package/lib/quoxon/objects/away.js +72 -0
- package/lib/quoxon/objects/fetch.js +17 -5
- package/lib/quoxon/objects/fleet.js +149 -0
- package/lib/quoxon/objects/health.js +186 -0
- package/lib/quoxon/objects/index.js +24 -3
- package/lib/quoxon/objects/mirror.js +137 -0
- package/lib/quoxon/objects/peek.js +17 -0
- package/lib/quoxon/objects/prove.js +130 -0
- package/lib/quoxon/objects/replay.js +125 -0
- package/lib/quoxon/objects/sitrep.js +58 -0
- package/lib/quoxon/objects/view.js +11 -0
- package/lib/quoxon/onboarding/copy.js +32 -4
- package/lib/quoxon/onboarding/credential.js +17 -4
- package/lib/quoxon/onboarding/dockerOffer.js +45 -0
- package/lib/quoxon/onboarding/failureCause.js +33 -0
- package/lib/quoxon/onboarding/finish.js +15 -0
- package/lib/quoxon/onboarding/index.js +34 -7
- package/lib/quoxon/onboarding/installRunner.js +64 -5
- package/lib/quoxon/onboarding/preflight.js +17 -3
- package/lib/quoxon/onboarding/probe.js +65 -0
- package/lib/quoxon/onboarding/view.js +76 -19
- package/lib/quoxon/orgLabel.js +10 -0
- package/lib/quoxon/orgSwitch.js +33 -0
- package/lib/quoxon/ownBroadcast.js +22 -0
- package/lib/quoxon/palette.js +43 -0
- package/lib/quoxon/print.js +9 -5
- package/lib/quoxon/profileSwitch.js +53 -0
- package/lib/quoxon/promptHistory.js +41 -0
- package/lib/quoxon/providers.js +48 -0
- package/lib/quoxon/qart.js +20 -51
- package/lib/quoxon/qdragon.js +410 -0
- package/lib/quoxon/qdragonFly.js +61 -0
- package/lib/quoxon/qdragonScenes.js +282 -0
- package/lib/quoxon/qeyes.js +213 -0
- package/lib/quoxon/qmotion.js +58 -37
- package/lib/quoxon/render.js +638 -119
- package/lib/quoxon/resend.js +72 -0
- package/lib/quoxon/roster.js +95 -9
- package/lib/quoxon/route.js +6 -5
- package/lib/quoxon/rows.js +711 -0
- package/lib/quoxon/rowsStore.js +80 -0
- package/lib/quoxon/screenContext.js +150 -0
- package/lib/quoxon/screenFrames.js +254 -0
- package/lib/quoxon/session.js +172 -2
- package/lib/quoxon/sessionDead.js +17 -0
- package/lib/quoxon/signin.js +125 -0
- package/lib/quoxon/slash/agents.js +112 -0
- package/lib/quoxon/slash/argSources.js +65 -0
- package/lib/quoxon/slash/args.js +65 -0
- package/lib/quoxon/slash/automate.js +89 -0
- package/lib/quoxon/slash/broadcast.js +140 -0
- package/lib/quoxon/slash/broadcastRun.js +108 -0
- package/lib/quoxon/slash/card.js +30 -0
- package/lib/quoxon/slash/challenge.js +114 -0
- package/lib/quoxon/slash/cliCommand.js +53 -0
- package/lib/quoxon/slash/cliLeaves.json +948 -0
- package/lib/quoxon/slash/compact.js +1 -1
- package/lib/quoxon/slash/confirm.js +30 -0
- package/lib/quoxon/slash/crystallize.js +69 -0
- package/lib/quoxon/slash/dashboard.js +136 -0
- package/lib/quoxon/slash/draft.js +54 -0
- package/lib/quoxon/slash/hatWalk.js +206 -0
- package/lib/quoxon/slash/inverses.js +77 -0
- package/lib/quoxon/slash/killer.js +24 -0
- package/lib/quoxon/slash/killerApi.js +16 -0
- package/lib/quoxon/slash/ledger.js +13 -8
- package/lib/quoxon/slash/listView.js +3 -1
- package/lib/quoxon/slash/modelCmd.js +261 -0
- package/lib/quoxon/slash/palette.js +21 -7
- package/lib/quoxon/slash/paletteController.js +117 -7
- package/lib/quoxon/slash/panels.js +21 -6
- package/lib/quoxon/slash/picker.js +20 -2
- package/lib/quoxon/slash/restart.js +71 -13
- package/lib/quoxon/slash/rowCmd.js +157 -0
- package/lib/quoxon/slash/rowWalk.js +175 -0
- package/lib/quoxon/slash/settings.js +16 -6
- package/lib/quoxon/slash/spawn.js +177 -0
- package/lib/quoxon/slash/stepUp.js +274 -0
- package/lib/quoxon/slash/stepUpCard.js +60 -0
- package/lib/quoxon/slash/stepUpCmds.js +78 -0
- package/lib/quoxon/slash/undoApi.js +38 -0
- package/lib/quoxon/slash/undoCmd.js +49 -0
- package/lib/quoxon/slash/undoWish.js +15 -0
- package/lib/quoxon/slash/watch.js +164 -0
- package/lib/quoxon/slash/wish.js +23 -0
- package/lib/quoxon/slash/wishCmd.js +32 -0
- package/lib/quoxon/slotChat.js +300 -0
- package/lib/quoxon/slotFormat.js +195 -0
- package/lib/quoxon/slotRun.js +131 -0
- package/lib/quoxon/sound/mapping.js +2 -1
- package/lib/quoxon/stray.js +22 -0
- package/lib/quoxon/suggestCommand.js +59 -0
- package/lib/quoxon/term.js +46 -11
- package/lib/quoxon/textBuffer.js +176 -2
- package/lib/quoxon/theme.js +58 -25
- package/lib/quoxon/tips.js +433 -0
- package/lib/quoxon/toolError.js +41 -0
- package/lib/quoxon/transcript.js +42 -4
- package/lib/quoxon/web/qface.js +18 -10
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +136 -0
- package/lib/quoxon/whatsnew.jsonl +89 -0
- package/lib/quoxon/windowOrg.js +113 -0
- package/lib/server/diag.js +129 -0
- package/lib/server/doctor.js +71 -0
- package/lib/server/install.js +149 -0
- package/lib/server/pluginEnable.js +161 -0
- package/lib/server/skew.js +56 -0
- package/lib/server/snapshot.js +131 -0
- package/lib/server/status.js +128 -0
- package/lib/server/sys.js +65 -0
- package/lib/server/uninstall.js +63 -0
- package/lib/server/update.js +234 -0
- package/lib/toolOutputShape.js +315 -0
- package/lib/tui/views/HyperionDeck.jsx +79 -0
- package/lib/tui/views/LucidDeck.jsx +209 -217
- package/package.json +4 -1
- package/lib/core/websiteSession.js +0 -148
package/lib/quoxon/connect.js
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
* the composer buffer or history, never sent to the chat stream, never written to a transcript.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { askText } from './ask.js';
|
|
17
|
+
import { consentCovers, connectConsentNote, needsConsent, providerLabel } from './consent.js';
|
|
18
|
+
import { providerState, SUBSCRIPTION_NAME } from './providers.js';
|
|
19
|
+
import { isLoopbackUrl, detectLanIp } from './hostUrls.js';
|
|
20
|
+
import { cmd } from './palette.js';
|
|
21
|
+
|
|
16
22
|
export const PROVIDERS = {
|
|
17
23
|
openai: {
|
|
18
24
|
id: 'openai', type: 'openai', name: 'OpenAI', field: 'api_key', card: true, minLen: 20,
|
|
@@ -41,14 +47,14 @@ export const PROVIDERS = {
|
|
|
41
47
|
id: 'custom', type: 'openai_compatible', name: 'Custom', field: 'api_key', card: false, url: true, custom: true, minLen: 8,
|
|
42
48
|
why: 'This endpoint wants an API key. It is stored in your org vault with the base URL.',
|
|
43
49
|
keyUrl: 'the provider of that endpoint',
|
|
44
|
-
disclosure: 'Custom endpoint: your prompts and tool results leave this server for that URL. Unless it is your own server, that is a third party. Your
|
|
50
|
+
disclosure: 'Custom endpoint: your prompts and tool results leave this server for that URL. Unless it is your own server, that is a third party. Your own AI data sharing consent applies (/consent).'
|
|
45
51
|
}
|
|
46
52
|
};
|
|
47
53
|
function hosted(id, name, field, keyUrl, who) {
|
|
48
54
|
return {
|
|
49
55
|
id, type: id, name, field, card: true, hosted: true, minLen: 20, keyUrl,
|
|
50
56
|
why: `Quox needs a ${name} key to use ${name} models for your org.`,
|
|
51
|
-
disclosure: `Hosted: your prompts and tool results leave this server for ${who}. Your
|
|
57
|
+
disclosure: `Hosted: your prompts and tool results leave this server for ${who}. Your own AI data sharing consent applies (/consent).`
|
|
52
58
|
};
|
|
53
59
|
}
|
|
54
60
|
function selfHosted(id, name, port) {
|
|
@@ -115,10 +121,11 @@ export const pickRow = (rows, type) => {
|
|
|
115
121
|
};
|
|
116
122
|
|
|
117
123
|
/**
|
|
118
|
-
* -> [[text, tone?]] lines for /connect. `res` is { ok:true, rows } from the vault list, or
|
|
119
|
-
*
|
|
124
|
+
* -> [[text, tone?]] lines for /connect. `res` is { ok:true, rows } from the vault list, or { ok:false, error };
|
|
125
|
+
* `caps` is the server's capabilities answer, the source of truth for whether each provider is usable
|
|
126
|
+
* (providers.js, shared with /model and /doctor). Anything neither can answer is "? unknown".
|
|
120
127
|
*/
|
|
121
|
-
export function connectionLines(res) {
|
|
128
|
+
export function connectionLines(res, caps = null) {
|
|
122
129
|
const STATUS_PROVIDERS = [PROVIDERS.openai, PROVIDERS.anthropic, PROVIDERS.ollama, PROVIDERS.openrouter, PROVIDERS.huggingface, PROVIDERS.groq, PROVIDERS.together, PROVIDERS.lmstudio, PROVIDERS.vllm, PROVIDERS.custom];
|
|
123
130
|
const out = [];
|
|
124
131
|
const ok = res && res.ok && Array.isArray(res.rows);
|
|
@@ -126,13 +133,19 @@ export function connectionLines(res) {
|
|
|
126
133
|
out.push(['Connections for this org:']);
|
|
127
134
|
for (const p of STATUS_PROVIDERS) {
|
|
128
135
|
const hint = p.custom ? '/connect custom https://<host>/v1' : p.card ? `/connect ${p.id}` : p.url ? `/connect ${p.id} http://<host>:${p.port || 11434}` : `quox vault create --type ${p.type}`;
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
if (
|
|
132
|
-
else if (
|
|
136
|
+
const st = providerState({ cap: p.id, vault: p.type }, caps, res);
|
|
137
|
+
const def = st.row;
|
|
138
|
+
if (st.state === 'unknown') out.push([`? ${p.name} unknown (vault not readable)`, 'warn']);
|
|
139
|
+
else if (st.state === 'connected' && def) out.push([`● ${p.name} connected · ${def.label || def.id} · ${testTag(def)}`, def.test_status === 'failed' ? 'warn' : 'ok']);
|
|
140
|
+
else if (st.state === 'connected') out.push([`● ${p.name} ready on this server (an instance-wide key, not in your org vault)`, 'ok']);
|
|
141
|
+
else if (st.state === 'stored') out.push([`◐ ${p.name} stored but not the default · quox vault set-default ${st.row.id}`, 'warn']);
|
|
142
|
+
else if (st.state === 'unavailable') out.push([`○ ${p.name} not available here${st.reason ? `: ${st.reason}` : ''}`]);
|
|
133
143
|
else out.push([`○ ${p.name} not connected · ${hint}`]);
|
|
134
144
|
}
|
|
135
|
-
|
|
145
|
+
const sub = providerState({ cap: 'claude_subscription' }, caps, res);
|
|
146
|
+
if (sub.state === 'connected') out.push([`● ${SUBSCRIPTION_NAME} ready on this server · /model claude-cli`, 'ok']);
|
|
147
|
+
else if (sub.state === 'unknown') out.push([`? ${SUBSCRIPTION_NAME} unknown (the server did not say)`, 'warn']);
|
|
148
|
+
else out.push([`○ ${SUBSCRIPTION_NAME} ${sub.reason || 'not signed in on this server'} · sign in from the dashboard Terminal`]);
|
|
136
149
|
if (!ok) { out.push([` could not read the vault: ${res?.error || 'no answer'}`, 'warn']); return out; }
|
|
137
150
|
const known = new Set(Object.values(PROVIDERS).map(p => p.type));
|
|
138
151
|
const others = new Map();
|
|
@@ -210,6 +223,9 @@ export async function connectFlow(api, providerId, secret, extra) {
|
|
|
210
223
|
return { ok: true, provider: p.id, last4, text: `✔ ${p.name} connected · key …${last4} · tested`, ...(p.disclosure ? { disclosure: p.disclosure } : {}) };
|
|
211
224
|
}
|
|
212
225
|
|
|
226
|
+
/** QUOXON-Z2H lane R: the one consent question asked after a cloud connect. Default No: cloud processing stays opt-in. */
|
|
227
|
+
export const consentAskLine = (type) => askText(`Allow Quox to send your conversations to ${providerLabel(type)}?`, { keys: 'y/N', who: 'Quox', action: `send your messages and any tool results to ${providerLabel(type)}, a third party, when it answers`, reason: 'Nothing is sent until you say yes; withdraw it any time in Settings > Privacy.' }) // no consent yet: nothing to chat with;
|
|
228
|
+
|
|
213
229
|
// ---- card controller ---------------------------------------------------------------------------
|
|
214
230
|
/**
|
|
215
231
|
* Owns the open card and its secret box. The app routes every input event here while the card is
|
|
@@ -233,11 +249,64 @@ export function createConnectController({ api, note, emit, refresh }) {
|
|
|
233
249
|
note(r.text, 'ok');
|
|
234
250
|
if (r.disclosure) note(r.disclosure, 'warn');
|
|
235
251
|
note('Your next turns can use it. Servers cache keys briefly, so allow up to a minute.');
|
|
252
|
+
await askConsent(p.type); // never meet the consent error cold: ask now, once
|
|
236
253
|
return;
|
|
237
254
|
}
|
|
238
255
|
card.phase = 'input'; card.error = r.text; refresh();
|
|
239
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* QUOXON-Z2H lane R: when `type` is a cloud provider and this user's OK is not in place, open the card on
|
|
259
|
+
* the consent question. y records it (the user's own direct act, POST /api/consent); anything else leaves
|
|
260
|
+
* it off and says how to give it later. -> true when the question opened.
|
|
261
|
+
*/
|
|
262
|
+
async function askConsent(type) {
|
|
263
|
+
if (!needsConsent(type) || !api.consentStatus) return false;
|
|
264
|
+
let rec = null; try { rec = await api.consentStatus(); } catch { rec = null; }
|
|
265
|
+
if (consentCovers(rec, type)) return false;
|
|
266
|
+
if (!api.grantConsent) { note(connectConsentNote(type), 'warn'); return false; }
|
|
267
|
+
box.clear();
|
|
268
|
+
card = { provider: providerFor(type) || Object.values(PROVIDERS).find((x) => x.type === type) || { id: type, type, name: providerLabel(type) }, phase: 'consent', consentType: type, error: null, question: consentAskLine(type) };
|
|
269
|
+
refresh();
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
async function answerConsent(yes) {
|
|
273
|
+
const type = card.consentType;
|
|
274
|
+
if (!yes) { close(); note(`Left off: nothing goes to ${providerLabel(type)} until you say so. ${connectConsentNote(type)}`, 'warn'); return; }
|
|
275
|
+
card.phase = 'saving'; refresh();
|
|
276
|
+
try {
|
|
277
|
+
await api.grantConsent(type);
|
|
278
|
+
close(); note(`✔ Your OK is recorded: ${providerLabel(type)} can now answer. Withdraw it any time: quox consent list, or Settings > Privacy.`, 'ok');
|
|
279
|
+
} catch (e) {
|
|
280
|
+
close(); note(`Quox could not record your OK (${e?.message || e}). Nothing was sent. Try /consent grant ${type}.`, 'warn');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* #1288: "Connect to <host>? It's on your private network." y/N. y runs offer.retry() (the save with a
|
|
285
|
+
* confirm for that exact origin) and hands its result to after(); n, Enter or Esc saves nothing.
|
|
286
|
+
*/
|
|
287
|
+
function askPrivate(offer, after) {
|
|
288
|
+
box.clear();
|
|
289
|
+
card = {
|
|
290
|
+
provider: PROVIDERS.ollama || { id: 'ollama', type: 'ollama', name: 'Ollama' },
|
|
291
|
+
phase: 'private', error: null, privateOffer: offer, privateAfter: after,
|
|
292
|
+
question: askText(`Connect to ${offer.host}?`, { keys: 'y/N', who: 'Quox', reason: "It's on your private network", action: `save ${offer.origin} as your org's Ollama. Only this address is allowed, after the server checks it answers as Ollama. Needs an org admin.` })
|
|
293
|
+
};
|
|
294
|
+
refresh();
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
async function answerPrivate(yes) {
|
|
298
|
+
const { privateOffer: offer, privateAfter: after } = card;
|
|
299
|
+
if (!yes) { close(); note(`Left blocked: ${offer.host} was not connected. Nothing was saved.`); return; }
|
|
300
|
+
card.phase = 'saving'; refresh();
|
|
301
|
+
let r;
|
|
302
|
+
try { r = await offer.retry(); } catch (e) { r = { ok: false, lines: [[`✖ ${e?.message || e}. Nothing was saved.`, 'warn']] }; }
|
|
303
|
+
close();
|
|
304
|
+
for (const [t, tone] of r.lines || []) note(t, tone);
|
|
305
|
+
if (after) await after(r);
|
|
306
|
+
}
|
|
240
307
|
return {
|
|
308
|
+
askConsent,
|
|
309
|
+
askPrivate,
|
|
241
310
|
isOpen: () => Boolean(card),
|
|
242
311
|
view: () => (card ? { card: { ...card }, mask: box.mask() } : null),
|
|
243
312
|
/** -> true when a card opened. */
|
|
@@ -263,6 +332,16 @@ export function createConnectController({ api, note, emit, refresh }) {
|
|
|
263
332
|
onEvent(e) {
|
|
264
333
|
if (!card) return false;
|
|
265
334
|
if (card.phase === 'saving') return true;
|
|
335
|
+
if (card.phase === 'private') {
|
|
336
|
+
if (e.type === 'text' && /^y(es)?$/i.test(String(e.text).trim())) { answerPrivate(true); return true; }
|
|
337
|
+
if ((e.type === 'text' && /^n(o)?$/i.test(String(e.text).trim())) || (e.type === 'key' && ['enter', 'escape', 'ctrl-c'].includes(e.name))) { answerPrivate(false); return true; }
|
|
338
|
+
return true; // the question takes only y or n
|
|
339
|
+
}
|
|
340
|
+
if (card.phase === 'consent') {
|
|
341
|
+
if (e.type === 'text' && /^y(es)?$/i.test(String(e.text).trim())) { answerConsent(true); return true; }
|
|
342
|
+
if ((e.type === 'text' && /^n(o)?$/i.test(String(e.text).trim())) || (e.type === 'key' && ['enter', 'escape', 'ctrl-c'].includes(e.name))) { answerConsent(false); return true; }
|
|
343
|
+
return true; // the question takes only y or n
|
|
344
|
+
}
|
|
266
345
|
if (e.type === 'paste') { box.paste(e.text); card.error = null; refresh(); return true; }
|
|
267
346
|
if (e.type === 'text') { box.type(e.text); card.error = null; refresh(); return true; }
|
|
268
347
|
if (e.type !== 'key') return true;
|
|
@@ -275,6 +354,61 @@ export function createConnectController({ api, note, emit, refresh }) {
|
|
|
275
354
|
};
|
|
276
355
|
}
|
|
277
356
|
|
|
357
|
+
|
|
358
|
+
// ---- private-network refusal: explain it, offer the instance gate, retry once --------------------
|
|
359
|
+
/** The vault refused the base URL because the address is private (HTTP 400, SSRF guard). */
|
|
360
|
+
export const isPrivateNetworkError = (s) => s?.status === 400 && /private|internal/i.test(s?.message || '');
|
|
361
|
+
|
|
362
|
+
const WHO_CAN = [
|
|
363
|
+
'Only an instance admin can allow this. Ask one to run:',
|
|
364
|
+
' quox server config lan-targets on'
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The save failed because the address is on a private network. -> { ok:false, lines, lanOffer? }.
|
|
369
|
+
* An admin gets a card and { lanOffer } (the app keeps it for "/connect yes"); anyone else is told
|
|
370
|
+
* who can. `retry` re-runs the same connect once the gate is on.
|
|
371
|
+
*/
|
|
372
|
+
/** The true scope of a saved private address: the instance gate is server-wide, not per connection. */
|
|
373
|
+
function privateScopeNote(origin) {
|
|
374
|
+
return [
|
|
375
|
+
[` ${origin} is a private address. This Quox server allows private-network`],
|
|
376
|
+
[' addresses for AI and monitoring connections. Turn off: quox server config lan-targets off']
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export async function lanGateCard(api, name, retry, { retried } = {}) {
|
|
381
|
+
const lines = [[`✖ ${name} is on a private network, so Quox did not save it.`, 'warn']];
|
|
382
|
+
const st = api.lanTargets ? await api.lanTargets() : null;
|
|
383
|
+
if (retried || st?.enabled) {
|
|
384
|
+
lines.push([' Private-network targets are already on, so Quox still refuses this address.']);
|
|
385
|
+
lines.push([' Loopback and link-local addresses are always blocked. Use the machine\'s LAN address.']);
|
|
386
|
+
return { ok: false, lines };
|
|
387
|
+
}
|
|
388
|
+
lines.push([' Quox blocks private addresses by default, so an AI cannot be pointed at internal services.']);
|
|
389
|
+
if (!api.setLanTargets || st?.status === 403) {
|
|
390
|
+
for (const l of WHO_CAN) lines.push([l, l.startsWith(' ') ? undefined : 'warn']);
|
|
391
|
+
return { ok: false, lines };
|
|
392
|
+
}
|
|
393
|
+
lines.push([' Allowing it applies to every AI and monitoring connection on this Quox server,']);
|
|
394
|
+
lines.push([' not just this one. Loopback and link-local stay blocked. Turn off later:']);
|
|
395
|
+
lines.push([' quox server config lan-targets off']);
|
|
396
|
+
lines.push([' Allow it and connect now? /connect yes (leave it blocked: /connect no)', 'warn']);
|
|
397
|
+
return { ok: false, lines, lanOffer: { name, retry } };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** "/connect yes": set the gate, then run the connect once more. Never offers a second time. */
|
|
401
|
+
export async function confirmLanAllow(api, offer) {
|
|
402
|
+
if (!offer?.retry) return { ok: false, lines: [['Nothing to confirm. Run /connect again.', 'warn']] };
|
|
403
|
+
const set = await api.setLanTargets(true);
|
|
404
|
+
if (!set?.ok) {
|
|
405
|
+
if (set?.status === 403) return { ok: false, lines: [['✖ Only an instance admin can allow that. Nothing was changed.', 'warn'], ...WHO_CAN.map(l => [l])] };
|
|
406
|
+
return { ok: false, lines: [[`✖ Could not turn it on (${set?.status ? `HTTP ${set.status}: ` : ''}${set?.message || 'failed'}). Nothing was saved.`, 'warn']] };
|
|
407
|
+
}
|
|
408
|
+
const r = await offer.retry();
|
|
409
|
+
return { ...r, lines: [['✔ Private-network targets are on for this instance. Retrying…', 'ok'], ...r.lines] };
|
|
410
|
+
}
|
|
411
|
+
|
|
278
412
|
// ---- Ollama (B3): a server URL, no key -----------------------------------------------------------
|
|
279
413
|
/** "/connect ollama <url> [model]" rest -> { url, model } ; a bare host gets http:// and :11434. */
|
|
280
414
|
export function parseOllamaArg(rest) {
|
|
@@ -314,25 +448,64 @@ export function ollamaModelRows(models, current) {
|
|
|
314
448
|
return out;
|
|
315
449
|
}
|
|
316
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Z2H-R4: how to make Ollama listen on the network. The official Linux installer runs it as a systemd
|
|
453
|
+
* service on 127.0.0.1, so `OLLAMA_HOST=0.0.0.0 ollama serve` fails there ("address already in use");
|
|
454
|
+
* the service override comes first, the plain serve only for a non-service run.
|
|
455
|
+
*/
|
|
456
|
+
export const OLLAMA_LISTEN_LINES = [
|
|
457
|
+
'Linux (installed as a service): ' + cmd('sudo systemctl edit ollama'),
|
|
458
|
+
' add under [Service]: ' + cmd('Environment="OLLAMA_HOST=0.0.0.0"'),
|
|
459
|
+
' then: ' + cmd('sudo systemctl restart ollama'),
|
|
460
|
+
'Not a service: ' + cmd('OLLAMA_HOST=0.0.0.0 ollama serve')
|
|
461
|
+
];
|
|
462
|
+
|
|
317
463
|
export const OLLAMA_REACH_HINT = [
|
|
318
|
-
'On the Ollama machine
|
|
464
|
+
'On the Ollama machine, make it listen on the network:',
|
|
465
|
+
...OLLAMA_LISTEN_LINES.map(l => ` ${l}`),
|
|
319
466
|
'and allow port 11434 through its firewall. "localhost" is not reachable from the server; use the machine\'s LAN address.'
|
|
320
467
|
];
|
|
321
468
|
|
|
469
|
+
/** Z2H-R3: the hint for a loopback Ollama URL, with this machine's LAN address filled in. */
|
|
470
|
+
export function ollamaLoopbackHint(lan) {
|
|
471
|
+
return [
|
|
472
|
+
'The Quox server runs in Docker, so "localhost" there is the container, not this machine.',
|
|
473
|
+
'Make Ollama listen on the network:',
|
|
474
|
+
...OLLAMA_LISTEN_LINES.map(l => ` ${l}`),
|
|
475
|
+
`Then connect with this machine's LAN address: ${cmd(`/connect ollama http://${lan}:11434`)}`
|
|
476
|
+
];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Z2H-R4: bare /connect ollama, the whole setup from nothing, with this machine's LAN address filled in. */
|
|
480
|
+
export function ollamaSetupCard(lan) {
|
|
481
|
+
return [
|
|
482
|
+
['Ollama runs AI models on your own machine, no account or key. To set it up:'],
|
|
483
|
+
[' 1. Install it: ' + cmd('curl -fsSL https://ollama.com/install.sh | sh')],
|
|
484
|
+
[' 2. Get a model that can use tools: ' + cmd('ollama pull qwen2.5')],
|
|
485
|
+
[' 3. Let the Quox server reach it:'],
|
|
486
|
+
...OLLAMA_LISTEN_LINES.map(l => [` ${l}`]),
|
|
487
|
+
[` 4. Connect: ${cmd(`/connect ollama http://${lan}:11434`)}`],
|
|
488
|
+
[' (Ollama on another machine? Use that machine\'s address.)']
|
|
489
|
+
];
|
|
490
|
+
}
|
|
491
|
+
|
|
322
492
|
/**
|
|
323
493
|
* Server-side probe (GET <url>/api/tags; human org admin; nothing stored), then the governed vault
|
|
324
494
|
* write as the org default: { base_url, default_model, allow_private_origin? }. A private address is
|
|
325
495
|
* allow-listed only for the exact origin probed. -> { ok, lines: [[text, tone?]] }
|
|
326
496
|
*/
|
|
327
|
-
export async function connectOllamaFlow(api, rest) {
|
|
497
|
+
export async function connectOllamaFlow(api, rest, { retried = false, confirmOrigin = null } = {}) {
|
|
328
498
|
const { url, model } = parseOllamaArg(rest);
|
|
329
|
-
if (!url) return { ok: false, lines:
|
|
499
|
+
if (!url) return { ok: false, lines: ollamaSetupCard((api.lanIp ? api.lanIp() : detectLanIp()) || '<lan-ip>') };
|
|
330
500
|
if (!api.ollamaProbe || !api.saveProviderKey) return { ok: false, lines: [['Connecting is not available in this mode.', 'warn']] };
|
|
331
501
|
const p = await api.ollamaProbe(url);
|
|
332
502
|
if (!p?.ok) {
|
|
333
503
|
if (p?.status === 403) return { ok: false, lines: [['✖ Connecting Ollama needs an org admin. Nothing was saved.', 'warn']] };
|
|
334
504
|
const lines = [[`✖ ${p?.message || 'Ollama could not be reached'}. Nothing was saved.`, 'warn']];
|
|
335
|
-
if (
|
|
505
|
+
if (isLoopbackUrl(url)) { // Z2H-R3: the server is in Docker, so loopback is the container; give the exact command
|
|
506
|
+
const lan = (api.lanIp ? api.lanIp() : detectLanIp()) || '<lan-ip>';
|
|
507
|
+
for (const h of ollamaLoopbackHint(lan)) lines.push([` ${h}`]);
|
|
508
|
+
} else if (p?.error === 'ollama_unreachable' || p?.status === 502) for (const h of OLLAMA_REACH_HINT) lines.push([` ${h}`]);
|
|
336
509
|
return { ok: false, lines };
|
|
337
510
|
}
|
|
338
511
|
const models = Array.isArray(p.models) ? p.models : [];
|
|
@@ -342,20 +515,32 @@ export async function connectOllamaFlow(api, rest) {
|
|
|
342
515
|
}
|
|
343
516
|
const credentials = { base_url: p.base_url, ...(def ? { default_model: def } : {}), ...(p.private ? { allow_private_origin: p.origin } : {}) };
|
|
344
517
|
const label = `Ollama · ${p.host}`;
|
|
345
|
-
|
|
518
|
+
const confirm = confirmOrigin ? { confirmPrivateOrigin: confirmOrigin } : {};
|
|
519
|
+
let s = await api.saveProviderKey({ type: 'ollama', label, credentials, isDefault: true, ...confirm });
|
|
346
520
|
if (!s?.ok && s?.status === 409 && api.connections && api.updateProviderCredential) {
|
|
347
521
|
// Same host connected before: refresh that credential (URL, default model, allowance) instead.
|
|
348
522
|
const c = await api.connections();
|
|
349
523
|
const row = c?.ok ? c.rows.find(r => r.integration_type === 'ollama' && r.label === label) : null;
|
|
350
|
-
s = row ? await api.updateProviderCredential({ id: row.id, credentials, isDefault: true }) : s;
|
|
524
|
+
s = row ? await api.updateProviderCredential({ id: row.id, credentials, isDefault: true, ...confirm }) : s;
|
|
525
|
+
}
|
|
526
|
+
if (!s?.ok && s?.confirmable && !confirmOrigin) {
|
|
527
|
+
// #1288: the server lets an org admin allow THIS one private address for their own Ollama. Ask y/N.
|
|
528
|
+
const origin = s.origin || p.origin;
|
|
529
|
+
return { ok: false, lines: [], privateAsk: { host: p.host, origin, retry: () => connectOllamaFlow(api, rest, { retried: true, confirmOrigin: origin }) } };
|
|
530
|
+
}
|
|
531
|
+
if (!s?.ok && confirmOrigin) {
|
|
532
|
+
return { ok: false, lines: [[`✖ Ollama at ${p.host} was not saved (${s?.status ? `HTTP ${s.status}: ` : ''}${s?.message || 'failed'}).`, 'warn']] };
|
|
533
|
+
}
|
|
534
|
+
if (!s?.ok && isPrivateNetworkError(s)) {
|
|
535
|
+
return lanGateCard(api, `Ollama at ${p.host}`, () => connectOllamaFlow(api, rest, { retried: true }), { retried });
|
|
351
536
|
}
|
|
352
537
|
if (!s?.ok) {
|
|
353
538
|
const role = s?.status === 403 ? ' Storing org connections needs the secrets.write capability; ask an org admin.' : '';
|
|
354
|
-
|
|
355
|
-
return { ok: false, lines: [[`✖ Ollama answered but the connection was not saved (${s?.status ? `HTTP ${s.status}: ` : ''}${s?.message || 'failed'}).${role}${lan}`, 'warn']] };
|
|
539
|
+
return { ok: false, lines: [[`✖ Ollama answered but the connection was not saved (${s?.status ? `HTTP ${s.status}: ` : ''}${s?.message || 'failed'}).${role}`, 'warn']] };
|
|
356
540
|
}
|
|
357
541
|
const lines = [[`✔ Ollama connected · ${p.host} · ${models.length} model${models.length === 1 ? '' : 's'}${def ? ` · default ${def}` : ''}`, 'ok']];
|
|
358
|
-
if (p.private) lines.push([` ${
|
|
542
|
+
if (p.private && confirmOrigin) lines.push([` ${confirmOrigin} is on your private network. Quox allows only this address, for this connection.`]);
|
|
543
|
+
else if (p.private) lines.push(...privateScopeNote(p.origin));
|
|
359
544
|
if (!models.length) lines.push([' No models installed yet. On that machine run: ollama pull qwen2.5', 'warn']);
|
|
360
545
|
else {
|
|
361
546
|
lines.push(...ollamaModelRows(models, def));
|
|
@@ -415,7 +600,7 @@ export function compatModelRows(models, current) {
|
|
|
415
600
|
* { ok:false, needsKey:true, extra } so the app can open the key card carrying base_url.
|
|
416
601
|
* -> { ok, lines: [[text, tone?]], needsKey?, extra? }
|
|
417
602
|
*/
|
|
418
|
-
export async function connectCompatFlow(api, presetId, rest) {
|
|
603
|
+
export async function connectCompatFlow(api, presetId, rest, { retried = false } = {}) {
|
|
419
604
|
const p = PROVIDERS[presetId];
|
|
420
605
|
if (!p?.url || p.id === 'ollama') return { ok: false, lines: [[`${presetId} cannot be connected by URL.`, 'warn']] };
|
|
421
606
|
const { url, model, error } = parseCompatArg(p.id, rest);
|
|
@@ -453,13 +638,15 @@ export async function connectCompatFlow(api, presetId, rest) {
|
|
|
453
638
|
const row = c?.ok ? c.rows.find(r => r.integration_type === p.type && r.label === label) : null;
|
|
454
639
|
s = row ? await api.updateProviderCredential({ id: row.id, credentials, isDefault: true }) : s;
|
|
455
640
|
}
|
|
641
|
+
if (!s?.ok && isPrivateNetworkError(s)) {
|
|
642
|
+
return lanGateCard(api, `${p.name} at ${pr.host}`, () => connectCompatFlow(api, presetId, rest, { retried: true }), { retried });
|
|
643
|
+
}
|
|
456
644
|
if (!s?.ok) {
|
|
457
645
|
const role = s?.status === 403 ? ' Storing org connections needs the secrets.write capability; ask an org admin.' : '';
|
|
458
|
-
|
|
459
|
-
return { ok: false, lines: [[`✖ ${p.name} answered but the connection was not saved (${s?.status ? `HTTP ${s.status}: ` : ''}${s?.message || 'failed'}).${role}${lan}`, 'warn']] };
|
|
646
|
+
return { ok: false, lines: [[`✖ ${p.name} answered but the connection was not saved (${s?.status ? `HTTP ${s.status}: ` : ''}${s?.message || 'failed'}).${role}`, 'warn']] };
|
|
460
647
|
}
|
|
461
648
|
const lines = [[`✔ ${p.name} connected · ${pr.host} · ${models.length} model${models.length === 1 ? '' : 's'}${model ? ` · default ${model}` : ''}`, 'ok']];
|
|
462
|
-
if (pr.private && !p.custom) lines.push(
|
|
649
|
+
if (pr.private && !p.custom) lines.push(...privateScopeNote(pr.origin));
|
|
463
650
|
lines.push(p.custom ? [` ${p.disclosure}`, 'warn'] : [` ${SELF_HOSTED_NOTE}`]);
|
|
464
651
|
lines.push(...compatModelRows(models, model));
|
|
465
652
|
lines.push([` Use it: /model ${p.id}${model ? '' : ' [model]'}`]);
|
|
@@ -5,14 +5,31 @@
|
|
|
5
5
|
import React from 'react';
|
|
6
6
|
import { Box, Text } from 'ink';
|
|
7
7
|
import { c, clip } from './theme.js';
|
|
8
|
-
import { Rule } from './render.js';
|
|
8
|
+
import { Rule, AskCard } from './render.js';
|
|
9
9
|
|
|
10
10
|
const h = React.createElement;
|
|
11
11
|
|
|
12
|
-
/** card: { provider: PROVIDERS entry, phase: 'input'|'saving', error?: string }, mask: string */
|
|
12
|
+
/** card: { provider: PROVIDERS entry, phase: 'input'|'saving'|'consent', error?: string, question?: string }, mask: string */
|
|
13
13
|
export function ConnectCard({ card, mask, cols }) {
|
|
14
14
|
const p = card.provider;
|
|
15
15
|
const w = Math.max(10, cols - 4);
|
|
16
|
+
// QUOXON-Z2H lane R: the one consent question after a cloud connect (y records the user's own OK).
|
|
17
|
+
if (card.phase === 'consent') return h(AskCard, { text: card.question, cols }); // QUOXON-ASK: the same card as an approval
|
|
18
|
+
// #1288: an org admin's own Ollama on a private address: y/N, then the save with a confirm for that origin.
|
|
19
|
+
if (card.phase === 'private') return h(AskCard, { text: card.question, cols });
|
|
20
|
+
if (card.phase === 'saving' && card.privateOffer) {
|
|
21
|
+
return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
|
|
22
|
+
h(Text, { color: c('dim') }, clip(` ◐ checking ${card.privateOffer.host} answers as Ollama, then saving…`, cols)));
|
|
23
|
+
}
|
|
24
|
+
if (card.phase === 'saving' && card.consentType) {
|
|
25
|
+
return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
|
|
26
|
+
h(Rule, { cols, color: 'sky' }),
|
|
27
|
+
h(Text, { color: c('sky'), bold: true }, clip(`Ⓠ ${p.name} is connected · one question`, cols)),
|
|
28
|
+
h(Text, { color: c('text'), wrap: 'wrap' }, ` Your messages and any tool results go to ${p.name}, a third party, when it answers. Nothing is sent until you say yes. You can withdraw this any time (Settings > Privacy).`),
|
|
29
|
+
h(Text, { color: c('warn'), bold: true }, clip(` ${card.question}`, cols)),
|
|
30
|
+
h(Text, { color: c('text') }, card.phase === 'saving' ? clip(' ◐ recording your OK…', cols) : clip(' y yes · n or Enter no · Esc no', cols)),
|
|
31
|
+
h(Rule, { cols, color: 'sky' }));
|
|
32
|
+
}
|
|
16
33
|
return h(Box, { flexDirection: 'column', width: cols, marginTop: 1 },
|
|
17
34
|
h(Rule, { cols, color: 'sky' }),
|
|
18
35
|
h(Text, { color: c('sky'), bold: true }, clip(`Ⓠ Connect ${p.name}${card.subtitle ? ` · ${card.subtitle}` : ''} · goes straight to your org vault, never into chat`, cols)),
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUOXON-CONSENT: AI data sharing consent, in words a stuck user can act on.
|
|
3
|
+
* Consent is PERSONAL (one record per user, POST /api/consent), so the person typing grants it; no admin is
|
|
4
|
+
* involved. Cloud providers that receive the conversation need it; a local model (Ollama) never does.
|
|
5
|
+
* This file only words things and shapes the two API calls. The gate itself is the server's.
|
|
6
|
+
*/
|
|
7
|
+
export const PRIVACY_PATH = '/settings#privacy';
|
|
8
|
+
const LABELS = { openai: 'OpenAI', perplexity: 'Perplexity', gemini: 'Google Gemini', google: 'Google', openrouter: 'OpenRouter', huggingface: 'Hugging Face', groq: 'Groq', together: 'Together AI', openai_compatible: 'your custom hosted endpoint' };
|
|
9
|
+
export const providerLabel = (p) => LABELS[String(p || '').toLowerCase()] || (p ? String(p) : 'your cloud AI provider');
|
|
10
|
+
|
|
11
|
+
// Keep in step with services/collector/lib/consentVerification.js THIRD_PARTY_PROVIDERS. Anthropic and local models are not in it.
|
|
12
|
+
export const CONSENT_PROVIDERS = ['openai', 'perplexity', 'gemini', 'google', 'openrouter', 'huggingface', 'groq', 'together', 'openai_compatible'];
|
|
13
|
+
export const needsConsent = (type) => CONSENT_PROVIDERS.includes(String(type || '').toLowerCase());
|
|
14
|
+
|
|
15
|
+
export const isConsentError = (ev) => ev?.type === 'error' && ev?.error === 'ai_consent_required';
|
|
16
|
+
|
|
17
|
+
/** The one-line headline for the error block (the card's first line). */
|
|
18
|
+
export function consentErrorText(ev) {
|
|
19
|
+
if (ev?.retryable) return 'Quox could not check your AI data sharing consent just now, so nothing was sent. Try again in a moment.';
|
|
20
|
+
return `Quox needs your OK before sending this conversation to ${providerLabel(ev?.provider)}.`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The lines under the headline: what is shared, how to grant it, the local alternative, the dashboard link. Short lines, no clipping. */
|
|
24
|
+
export function consentHintLines(ev, dashboardBase) {
|
|
25
|
+
if (ev?.retryable) return [];
|
|
26
|
+
const who = providerLabel(ev?.provider);
|
|
27
|
+
const prov = ev?.provider || '<provider>';
|
|
28
|
+
const why = ev?.reason === 'consent_expired' ? 'Your earlier OK is out of date and needs renewing.'
|
|
29
|
+
: ev?.reason === 'provider_blocked' ? `You have blocked ${who} in your privacy settings; granting turns it back on.` : null;
|
|
30
|
+
const lines = [];
|
|
31
|
+
if (why) lines.push(why);
|
|
32
|
+
lines.push(`Shared: your messages and any tool results go to ${who}, a third party. Nothing was sent.`);
|
|
33
|
+
lines.push('Grant it: /consent grant (records your OK and sends this message again), or quox consent grant --provider ' + prov + ' in a shell.');
|
|
34
|
+
lines.push('Consent is personal, so you can give it yourself; no admin is needed.');
|
|
35
|
+
if (dashboardBase) lines.push(`Or in the dashboard: ${String(dashboardBase).replace(/\/$/, '')}${ev?.grant?.dashboard_path || PRIVACY_PATH}`);
|
|
36
|
+
lines.push('No cloud at all: a local model (Ollama) needs no consent. /connect ollama');
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** GET /api/consent record -> { granted, providers: { name: bool } }. */
|
|
41
|
+
export function consentState(rec) {
|
|
42
|
+
const providers = rec?.provider_consent && typeof rec.provider_consent === 'object' ? rec.provider_consent : {};
|
|
43
|
+
return { granted: Boolean(rec?.ai_data_sharing_consent), providers };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Is `provider` allowed to receive data under this record? Mirrors the server: overall consent on, provider not set false. */
|
|
47
|
+
export const consentCovers = (rec, provider) => {
|
|
48
|
+
const s = consentState(rec);
|
|
49
|
+
return s.granted && s.providers[String(provider || '').toLowerCase()] !== false;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function consentStatusLines(rec, { provider = null } = {}) {
|
|
53
|
+
const s = consentState(rec);
|
|
54
|
+
const names = Object.entries(s.providers).map(([k, v]) => `${k} ${v ? 'yes' : 'blocked'}`);
|
|
55
|
+
const lines = [[s.granted ? '✔ AI data sharing: you have given your OK.' : '· AI data sharing: not given yet, so nothing is sent to a cloud AI.', s.granted ? 'ok' : 'warn']];
|
|
56
|
+
if (names.length) lines.push([`Per provider: ${names.join(', ')}.`]);
|
|
57
|
+
if (!s.granted) lines.push([`/consent grant${provider ? ` ${provider}` : ''} gives it. Your messages and tool results then go to the cloud AI you chose. A local model (Ollama) never needs it.`]);
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The body for POST /api/consent: keeps every other provider's setting (the server replaces the map wholesale). */
|
|
62
|
+
export function grantBody(rec, provider) {
|
|
63
|
+
const keep = consentState(rec).providers;
|
|
64
|
+
const p = String(provider || '').toLowerCase();
|
|
65
|
+
return { ai_data_sharing_consent: true, provider_consent: { ...keep, ...(p ? { [p]: true } : {}) } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What to say after /connect saves a hosted key and consent is not yet in place. */
|
|
69
|
+
export function connectConsentNote(providerId) {
|
|
70
|
+
return `One more step before it can answer: ${providerLabel(providerId)} receives your messages, so Quox needs your OK. /consent grant ${providerId}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* QUOXON-Z2H lane R: the plain-text message `quox --print` gives for ai_consent_required, naming the exact
|
|
75
|
+
* shell command (there is no /consent in a one-shot run). profile = the -p the user named, if any.
|
|
76
|
+
*/
|
|
77
|
+
export function consentPrintMessage(ev, profile = null) {
|
|
78
|
+
if (ev?.retryable) return consentErrorText(ev);
|
|
79
|
+
const who = providerLabel(ev?.provider);
|
|
80
|
+
const prov = ev?.provider || '<provider>';
|
|
81
|
+
const p = profile ? `-p ${profile} ` : '';
|
|
82
|
+
return `${consentErrorText(ev)} Nothing was sent.\n`
|
|
83
|
+
+ ` Your messages and any tool results would go to ${who}, a third party. Consent is personal; no admin is needed.\n`
|
|
84
|
+
+ ` Grant it, then send again: quox ${p}consent grant --provider ${prov}\n`
|
|
85
|
+
+ ' Or use a local model (Ollama), which needs no consent.';
|
|
86
|
+
}
|
package/lib/quoxon/copy.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** /copy helpers: pick the text and build the OSC 52 clipboard escape. */
|
|
2
|
+
import { stripMarks } from './palette.js';
|
|
2
3
|
|
|
3
4
|
export function osc52(text, env = process.env) {
|
|
4
|
-
const seq = `\x1b]52;c;${Buffer.from(
|
|
5
|
+
const seq = `\x1b]52;c;${Buffer.from(stripMarks(text), 'utf8').toString('base64')}\x07`;
|
|
5
6
|
return env.TMUX ? `\x1bPtmux;${seq.replace(/\x1b/g, '\x1b\x1b')}\x1b\\` : seq;
|
|
6
7
|
}
|
|
7
8
|
|
|
@@ -23,11 +24,11 @@ export function pickCopy(answers, arg) {
|
|
|
23
24
|
const list = (answers || []).filter(a => a.text && a.text.trim());
|
|
24
25
|
if (!list.length) return { error: 'No answer to copy yet.' };
|
|
25
26
|
if (arg === 'code') {
|
|
26
|
-
for (let i = list.length - 1; i >= 0; i--) { const b = codeBlocks(list[i].text); if (b.length) return { text: b[b.length - 1] }; }
|
|
27
|
+
for (let i = list.length - 1; i >= 0; i--) { const b = codeBlocks(list[i].text); if (b.length) return { text: stripMarks(b[b.length - 1]) }; }
|
|
27
28
|
return { error: 'No code block in the answers so far.' };
|
|
28
29
|
}
|
|
29
30
|
const n = arg ? Number(arg) : 1;
|
|
30
31
|
if (!Number.isInteger(n) || n < 1) return { error: 'Usage: /copy [N|code]' };
|
|
31
32
|
if (n > list.length) return { error: `Only ${list.length} answer${list.length === 1 ? '' : 's'} so far.` };
|
|
32
|
-
return { text: list[list.length - n].text };
|
|
33
|
+
return { text: stripMarks(list[list.length - n].text) };
|
|
33
34
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The restart countdown line, fitted to the width: full text when it fits, shorter forms below, never wrapped. */
|
|
2
|
+
export function countdownLine(secs, cols) {
|
|
3
|
+
const room = Math.max(20, (cols || 80) - 4); // paddingLeft 2 plus a margin
|
|
4
|
+
const forms = [
|
|
5
|
+
`↻ New version ready. Restarting in ${secs}s · Esc to stay on this version · Enter to restart now`,
|
|
6
|
+
`↻ New version · restarting in ${secs}s · Esc stay · Enter now`,
|
|
7
|
+
`↻ Restarting in ${secs}s · Esc stay · Enter now`,
|
|
8
|
+
`↻ Restart in ${secs}s · Esc stay`,
|
|
9
|
+
];
|
|
10
|
+
return forms.find(f => f.length <= room) || forms[forms.length - 1];
|
|
11
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic script for `quox --demo --autoplay`: the same timings on every run, so a screen
|
|
3
|
+
* recording is repeatable. No Math.random, no wall clock in the plan; only fixed numbers and a seeded PRNG.
|
|
4
|
+
*/
|
|
5
|
+
export const AUTOPLAY_LABEL = 'DEMO: demo data, nothing real runs.';
|
|
6
|
+
export const AUTOPLAY_REQUEST = 'Show me what you can do (demo)';
|
|
7
|
+
export const AUTOPLAY_PROOF = '/proof';
|
|
8
|
+
export const AUTOPLAY = Object.freeze({ seed: 20261013, startMs: 900, typeMinMs: 38, typeMaxMs: 78, afterTypeMs: 450, approvalHoldMs: 2500, afterKeyMs: 400, afterTurnMs: 900, finalHoldMs: 600 });
|
|
9
|
+
|
|
10
|
+
/** Small seeded PRNG (mulberry32): deterministic across runs and platforms. */
|
|
11
|
+
export function prng(seed) {
|
|
12
|
+
let a = seed >>> 0;
|
|
13
|
+
return () => {
|
|
14
|
+
a = (a + 0x6D2B79F5) >>> 0;
|
|
15
|
+
let t = a;
|
|
16
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
17
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
18
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Per-character delays (ms) for typing `text` at a human pace; identical for the same seed. */
|
|
23
|
+
export function typingPlan(text, seed = AUTOPLAY.seed, { min = AUTOPLAY.typeMinMs, max = AUTOPLAY.typeMaxMs } = {}) {
|
|
24
|
+
const r = prng(seed);
|
|
25
|
+
return [...text].map(() => Math.round(min + r() * (max - min)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Is autoplay requested? flag or QUOX_DEMO_AUTOPLAY=1. */
|
|
29
|
+
export const autoplayOn = (flag, env = process.env) => Boolean(flag) || /^(1|true|yes|on)$/i.test(String(env.QUOX_DEMO_AUTOPLAY || ''));
|
|
@@ -14,7 +14,7 @@ export const DEMO_STEPS = (turnId) => [
|
|
|
14
14
|
{ when: 'reject', wait: 300, ev: { type: 'text.delta', turn_id: turnId, content: 'Understood. The note is saved; web on edge-02 was NOT restarted because you denied it (demo data).' } },
|
|
15
15
|
{ when: 'reject', wait: 200, ev: { type: 'turn.finished', turn_id: turnId, status: 'completed', conversation_id: 'demo-conv' } },
|
|
16
16
|
{ when: 'approve', wait: 200, ev: { type: 'approval.resolved', turn_id: turnId, call_id: 'c2', inbox_item_id: 'demo-item', decision: 'approved' } },
|
|
17
|
-
{ when: 'approve', wait: 900, ev: { type: 'tool.finished', turn_id: turnId, call_id: 'c2', tool: 'host_restart', status: 'ok', result_summary: '
|
|
17
|
+
{ when: 'approve', wait: 900, ev: { type: 'tool.finished', turn_id: turnId, call_id: 'c2', tool: 'host_restart', status: 'ok', result_summary: 'restarted', duration_ms: 2300, envelope_id: 'env2', receipt: { ward_seq: 4069513, verified: true } } },
|
|
18
18
|
{ when: 'approve', wait: 100, ev: { type: 'trace', turn_id: turnId, protocol: 'aocl', label: 'ingress ok identity ok policy ok' } },
|
|
19
19
|
{ when: 'approve', wait: 100, ev: { type: 'trace', turn_id: turnId, protocol: 'ward', label: 'witnessed', ref: '4069513' } },
|
|
20
20
|
{ when: 'approve', wait: 300, ev: { type: 'text.delta', turn_id: turnId, content: 'Done. The note is saved and web on edge-02 restarted (demo data, nothing real ran).' } },
|
package/lib/quoxon/demo.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
1
2
|
import { DEMO_STEPS } from './demo.fixtures.js';
|
|
2
3
|
|
|
3
4
|
const sleep = (ms, signal) => new Promise((res) => {
|
|
@@ -7,7 +8,32 @@ const sleep = (ms, signal) => new Promise((res) => {
|
|
|
7
8
|
});
|
|
8
9
|
|
|
9
10
|
/** A fake api with the same surface as createApi(), driven by DEMO_STEPS. */
|
|
10
|
-
|
|
11
|
+
/**
|
|
12
|
+
* WALK-FIXES pty seam (demo only, never a real server): decisionsFile is a JSON state file
|
|
13
|
+
* { items: [decision inbox items], log: [], answerDelayMs?, approvalsDelayMs? }. approvals() lists the open ones,
|
|
14
|
+
* answerDecision() marks one answered and appends { id, key, note, at } to log, so a test in another process reads
|
|
15
|
+
* exactly what was submitted, and when.
|
|
16
|
+
*/
|
|
17
|
+
function decisionStore(file) {
|
|
18
|
+
const load = () => JSON.parse(readFileSync(file, 'utf8'));
|
|
19
|
+
const wait = (ms) => new Promise(r => setTimeout(r, ms || 0));
|
|
20
|
+
return {
|
|
21
|
+
// staleMs: an answered item is still listed for this long (a server that lags behind its own answers)
|
|
22
|
+
async open() { const d = load(); await wait(d.approvalsDelayMs); return d.items.filter(x => x.status === 'open' || (d.staleMs && x.answeredAt && Date.now() - x.answeredAt < d.staleMs)); },
|
|
23
|
+
async answer(id, key, note) {
|
|
24
|
+
const d0 = load(); await wait(d0.answerDelayMs);
|
|
25
|
+
const d = load(); const it = d.items.find(x => x.id === id);
|
|
26
|
+
if (!it || it.status !== 'open') { const e = new Error('already handled'); e.status = 404; throw e; }
|
|
27
|
+
it.status = 'answered'; it.answeredAt = Date.now(); it.answer = { key, note: note || null };
|
|
28
|
+
d.log.push({ id, key, note: note || null, at: Date.now() });
|
|
29
|
+
writeFileSync(file, JSON.stringify(d));
|
|
30
|
+
return { ok: true };
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createDemoApi({ speed = 1, objects = false, decisionsFile = null } = {}) {
|
|
36
|
+
const decs = decisionsFile ? decisionStore(decisionsFile) : null;
|
|
11
37
|
let gate = null;
|
|
12
38
|
let decision = 'approve';
|
|
13
39
|
return {
|
|
@@ -26,10 +52,25 @@ export function createDemoApi({ speed = 1 } = {}) {
|
|
|
26
52
|
async steer() { return { ok: false, reason: 'unsupported' }; },
|
|
27
53
|
async interrupt() { return { ok: false }; },
|
|
28
54
|
async health() { return { ok: true, url: 'demo' }; },
|
|
29
|
-
|
|
55
|
+
// the open approval is in the inbox while the demo holds at the gate, so a poll never prunes its card
|
|
56
|
+
async approvals() { return [...(gate ? [{ id: 'demo-item', title: 'host_restart on web on edge-02', approval_id: 'demo-appr', created_at: new Date().toISOString() }] : []), ...(decs ? await decs.open() : [])]; },
|
|
57
|
+
...(decs ? { async answerDecision(id, key, { note } = {}) { return decs.answer(id, key, note); }, async approvalStats() { return { awaiting: (await decs.open()).length + (gate ? 1 : 0) }; } } : {}),
|
|
30
58
|
async skills() { return [{ id: 'sk1', name: 'daily-fleet-check', description: 'Check fleet health and summarise.', edition: 'stable' }]; },
|
|
31
59
|
async skill(id) { return { id, name: 'daily-fleet-check', description: 'Check fleet health and summarise.', edition: 'stable' }; },
|
|
32
60
|
async conversationExists() { return { exists: true }; },
|
|
33
|
-
|
|
61
|
+
// /proof works in the demo journey (main.js asks for it); the other object views stay unavailable offline
|
|
62
|
+
...(objects ? { objects: {
|
|
63
|
+
async proof(tool) {
|
|
64
|
+
const env = { id: tool.envelopeId, corr: 'demo-corr', payload: { success: true, tool_name: tool.tool } };
|
|
65
|
+
return { data: { tool, envelope: { ok: true, env },
|
|
66
|
+
aocl: { ok: true, list: [{ intent: 'aocl.identity.verified' }, { intent: 'aocl.policy.evaluated', payload: { allPassed: true } }] },
|
|
67
|
+
receipt: { ok: true, result: { status: 'WITNESSED', entry: { seq: 4069513 } } } } };
|
|
68
|
+
},
|
|
69
|
+
...Object.fromEntries(['skill', 'agent', 'workflowRun', 'crystal', 'memory', 'rules', 'reconcile'].map((k) => [k, async () => ({ error: 'Object views are not available in the demo.' })]))
|
|
70
|
+
} } : {}),
|
|
71
|
+
async respond(_id, action) {
|
|
72
|
+
if (decs && _id !== 'demo-item') return decs.answer(_id, action === 'reject' ? 'n' : 'y', null); // a yes/no decision answered as an approval (the bulk card)
|
|
73
|
+
decision = action === 'reject' ? 'reject' : 'approve'; const g = gate; gate = null; g?.(); return { ok: true };
|
|
74
|
+
}
|
|
34
75
|
};
|
|
35
76
|
}
|