@oar-sh/oar 0.9.0
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/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live Grok subscription quota for the Check Usage card.
|
|
5
|
+
*
|
|
6
|
+
* OIDC subscription logins have no USD billing; the authoritative quota is
|
|
7
|
+
* the weekly SuperGrok percentage shown on grok.com → Settings → Usage. The
|
|
8
|
+
* Grok CLI's chat proxy serves the same payload:
|
|
9
|
+
*
|
|
10
|
+
* GET https://cli-chat-proxy.grok.com/v1/billing?format=credits
|
|
11
|
+
* Authorization: Bearer <key from ~/.grok/auth.json>
|
|
12
|
+
*
|
|
13
|
+
* (Discovered 2026-08-08 by tracing the TUI's subscription watcher; the ACP
|
|
14
|
+
* agent itself exposes no quota RPC — `x.ai/auth/check_subscription` is a
|
|
15
|
+
* leader-side method.)
|
|
16
|
+
*
|
|
17
|
+
* The bearer key is the relay host's own CLI login (same host-login trust
|
|
18
|
+
* model as turn execution). It is read fresh per fetch — the CLI rotates it
|
|
19
|
+
* every few hours — and never logged or forwarded to clients.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
export const GROK_BILLING_URL = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits';
|
|
27
|
+
const FETCH_TIMEOUT_MS = 6_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read the newest OIDC access key from the Grok CLI's auth store. Returns
|
|
31
|
+
* null when the CLI is not logged in. Never throws.
|
|
32
|
+
*/
|
|
33
|
+
export function readGrokCliAuthKey({ homeDir = os.homedir(), fsImpl = fs } = {}) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = fsImpl.readFileSync(path.join(homeDir, '.grok', 'auth.json'), 'utf8');
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
38
|
+
let best = null;
|
|
39
|
+
for (const entry of Object.values(parsed)) {
|
|
40
|
+
const key = String(entry?.key || '').trim();
|
|
41
|
+
if (!key) continue;
|
|
42
|
+
const createTime = Date.parse(entry?.create_time || '') || 0;
|
|
43
|
+
if (!best || createTime > best.createTime) {
|
|
44
|
+
best = { key, createTime, expiresAt: String(entry?.expires_at || '') || null };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return best ? { key: best.key, expiresAt: best.expiresAt } : null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toPercent(value) {
|
|
54
|
+
const numeric = Number(value);
|
|
55
|
+
return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toIso(value) {
|
|
59
|
+
const parsed = Date.parse(String(value || ''));
|
|
60
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalize the proxy's billing payload into the fields the plan card needs.
|
|
65
|
+
* Pure; returns null when nothing usable is present.
|
|
66
|
+
*/
|
|
67
|
+
export function normalizeGrokBillingCredits(payload = null) {
|
|
68
|
+
const config = payload?.config && typeof payload.config === 'object' ? payload.config : payload;
|
|
69
|
+
if (!config || typeof config !== 'object') return null;
|
|
70
|
+
|
|
71
|
+
const usagePercent = toPercent(config.creditUsagePercent);
|
|
72
|
+
const period = config.currentPeriod && typeof config.currentPeriod === 'object'
|
|
73
|
+
? config.currentPeriod
|
|
74
|
+
: {};
|
|
75
|
+
const periodType = String(period.type || '').trim();
|
|
76
|
+
const products = (Array.isArray(config.productUsage) ? config.productUsage : [])
|
|
77
|
+
.map((entry) => ({
|
|
78
|
+
product: String(entry?.product || '').trim(),
|
|
79
|
+
usagePercent: toPercent(entry?.usagePercent),
|
|
80
|
+
}))
|
|
81
|
+
.filter((entry) => entry.product && entry.usagePercent !== null);
|
|
82
|
+
|
|
83
|
+
const normalized = {
|
|
84
|
+
usagePercent,
|
|
85
|
+
// "USAGE_PERIOD_TYPE_WEEKLY" → "weekly"
|
|
86
|
+
periodType: periodType.replace(/^USAGE_PERIOD_TYPE_/, '').toLowerCase() || null,
|
|
87
|
+
periodStart: toIso(period.start || config.billingPeriodStart),
|
|
88
|
+
periodEnd: toIso(period.end || config.billingPeriodEnd),
|
|
89
|
+
products,
|
|
90
|
+
};
|
|
91
|
+
if (normalized.usagePercent === null && !products.length) return null;
|
|
92
|
+
return normalized;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the best-effort fetcher the usage route calls. Errors and missing
|
|
97
|
+
* logins yield `null` so the card degrades to the local estimated view.
|
|
98
|
+
*/
|
|
99
|
+
export function createGrokBillingUsageFetcher({
|
|
100
|
+
fetchImpl = globalThis.fetch,
|
|
101
|
+
readAuthKeyImpl = readGrokCliAuthKey,
|
|
102
|
+
url = GROK_BILLING_URL,
|
|
103
|
+
timeoutMs = FETCH_TIMEOUT_MS,
|
|
104
|
+
} = {}) {
|
|
105
|
+
return async function fetchGrokBillingUsage() {
|
|
106
|
+
if (typeof fetchImpl !== 'function') return null;
|
|
107
|
+
const auth = readAuthKeyImpl();
|
|
108
|
+
if (!auth?.key) return null;
|
|
109
|
+
try {
|
|
110
|
+
const response = await fetchImpl(url, {
|
|
111
|
+
headers: { Authorization: `Bearer ${auth.key}` },
|
|
112
|
+
signal: AbortSignal.timeout?.(timeoutMs),
|
|
113
|
+
});
|
|
114
|
+
if (!response?.ok) return null;
|
|
115
|
+
return normalizeGrokBillingCredits(await response.json());
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function parseAttachmentList(value) {
|
|
4
|
+
if (Array.isArray(value)) return value;
|
|
5
|
+
if (!value) return [];
|
|
6
|
+
try {
|
|
7
|
+
const parsed = JSON.parse(value);
|
|
8
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
9
|
+
} catch {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createImageContextService({
|
|
15
|
+
db,
|
|
16
|
+
repository,
|
|
17
|
+
uuidv4,
|
|
18
|
+
} = {}) {
|
|
19
|
+
if (!db || !repository || typeof uuidv4 !== 'function') {
|
|
20
|
+
throw new Error('Image context service dependencies are required');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const getMessage = db.prepare(`
|
|
24
|
+
SELECT id, conversation_id, role, attachments, timestamp
|
|
25
|
+
FROM messages
|
|
26
|
+
WHERE id = ?
|
|
27
|
+
`);
|
|
28
|
+
|
|
29
|
+
function resolveGeneratedAttachmentTarget({
|
|
30
|
+
conversationId,
|
|
31
|
+
target,
|
|
32
|
+
createdAt,
|
|
33
|
+
} = {}) {
|
|
34
|
+
const messageId = String(target?.messageId || '').trim();
|
|
35
|
+
const imageId = String(target?.imageId || '').trim();
|
|
36
|
+
const requestedNodeId = String(target?.nodeId || '').trim();
|
|
37
|
+
if (!conversationId || !messageId || !imageId) {
|
|
38
|
+
throw new Error('Image edit target requires messageId and imageId');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const message = getMessage.get(messageId);
|
|
42
|
+
if (!message || message.conversation_id !== conversationId || message.role !== 'assistant') {
|
|
43
|
+
throw new Error('Image edit target is not available in this conversation');
|
|
44
|
+
}
|
|
45
|
+
const attachment = parseAttachmentList(message.attachments).find((entry) => (
|
|
46
|
+
String(entry?.generatedImage?.imageId || '').trim() === imageId
|
|
47
|
+
));
|
|
48
|
+
if (!attachment?.generatedImage) {
|
|
49
|
+
throw new Error('Image edit target is not a generated image');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let node = repository.getNodeByAttachment.get(messageId, imageId);
|
|
53
|
+
if (node && requestedNodeId && node.id !== requestedNodeId) {
|
|
54
|
+
throw new Error('Image edit target node does not match the generated image');
|
|
55
|
+
}
|
|
56
|
+
if (node) {
|
|
57
|
+
return { node, attachment, reconstructionMode: 'native' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const now = String(createdAt || new Date().toISOString());
|
|
61
|
+
const imageSessionId = `img_session_${uuidv4()}`;
|
|
62
|
+
const bridgeOperationId = `img_op_${uuidv4()}`;
|
|
63
|
+
const nodeId = `img_node_${uuidv4()}`;
|
|
64
|
+
repository.insertSession.run({
|
|
65
|
+
id: imageSessionId,
|
|
66
|
+
conversationId,
|
|
67
|
+
origin: 'legacy_reconstructed',
|
|
68
|
+
schemaVersion: repository.imageSchemaVersion,
|
|
69
|
+
createdAt: message.timestamp || now,
|
|
70
|
+
updatedAt: now,
|
|
71
|
+
});
|
|
72
|
+
repository.insertOperation.run({
|
|
73
|
+
id: bridgeOperationId,
|
|
74
|
+
imageSessionId,
|
|
75
|
+
sourceMessageId: messageId,
|
|
76
|
+
queueMessageId: `legacy:${messageId}:${imageId}`,
|
|
77
|
+
kind: 'legacy_edit',
|
|
78
|
+
parentNodeId: null,
|
|
79
|
+
prompt: '',
|
|
80
|
+
selectedImageModel: 'legacy-generated-image',
|
|
81
|
+
orchestrationModel: null,
|
|
82
|
+
provider: 'openai',
|
|
83
|
+
executionMode: 'direct_images',
|
|
84
|
+
parametersJson: '{}',
|
|
85
|
+
requestFingerprint: `legacy:${messageId}:${imageId}`,
|
|
86
|
+
idempotencyKey: bridgeOperationId,
|
|
87
|
+
replacesOperationId: null,
|
|
88
|
+
createdAt: message.timestamp || now,
|
|
89
|
+
});
|
|
90
|
+
repository.insertNode.run({
|
|
91
|
+
id: nodeId,
|
|
92
|
+
imageSessionId,
|
|
93
|
+
operationId: bridgeOperationId,
|
|
94
|
+
assistantMessageId: messageId,
|
|
95
|
+
attachmentImageId: imageId,
|
|
96
|
+
outputIndex: 0,
|
|
97
|
+
createdAt: message.timestamp || now,
|
|
98
|
+
});
|
|
99
|
+
node = repository.getNode.get(nodeId);
|
|
100
|
+
return { node, attachment, reconstructionMode: 'image_only' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
resolveGeneratedAttachmentTarget,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { createImageContextService } from './image-context-service.mjs';
|
|
5
|
+
|
|
6
|
+
function canonicalJson(value) {
|
|
7
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
8
|
+
if (value && typeof value === 'object') {
|
|
9
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
|
10
|
+
}
|
|
11
|
+
return JSON.stringify(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sha256(value) {
|
|
15
|
+
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createImageOperationService({
|
|
19
|
+
db,
|
|
20
|
+
repository,
|
|
21
|
+
uuidv4,
|
|
22
|
+
} = {}) {
|
|
23
|
+
const contextService = createImageContextService({ db, repository, uuidv4 });
|
|
24
|
+
|
|
25
|
+
function createEnqueuedOperation({
|
|
26
|
+
conversationId,
|
|
27
|
+
sourceMessageId,
|
|
28
|
+
queueMessageId,
|
|
29
|
+
prompt,
|
|
30
|
+
selectedImageModel,
|
|
31
|
+
orchestrationModel = null,
|
|
32
|
+
executionMode = 'direct_images',
|
|
33
|
+
parameters = {},
|
|
34
|
+
attachments = [],
|
|
35
|
+
imageTarget = null,
|
|
36
|
+
replacesOperationId = null,
|
|
37
|
+
createdAt = new Date().toISOString(),
|
|
38
|
+
} = {}) {
|
|
39
|
+
const operationId = `img_op_${uuidv4()}`;
|
|
40
|
+
let imageSessionId;
|
|
41
|
+
let parentNodeId = null;
|
|
42
|
+
let reconstructionMode = executionMode === 'native_responses' ? 'native' : 'image_only';
|
|
43
|
+
let kind = 'generate';
|
|
44
|
+
|
|
45
|
+
if (imageTarget) {
|
|
46
|
+
const resolved = contextService.resolveGeneratedAttachmentTarget({
|
|
47
|
+
conversationId,
|
|
48
|
+
target: imageTarget,
|
|
49
|
+
createdAt,
|
|
50
|
+
});
|
|
51
|
+
imageSessionId = resolved.node.image_session_id;
|
|
52
|
+
parentNodeId = resolved.node.id;
|
|
53
|
+
reconstructionMode = resolved.reconstructionMode;
|
|
54
|
+
kind = resolved.reconstructionMode === 'image_only' ? 'legacy_edit' : 'edit';
|
|
55
|
+
} else {
|
|
56
|
+
imageSessionId = `img_session_${uuidv4()}`;
|
|
57
|
+
repository.insertSession.run({
|
|
58
|
+
id: imageSessionId,
|
|
59
|
+
conversationId,
|
|
60
|
+
origin: 'native',
|
|
61
|
+
schemaVersion: repository.imageSchemaVersion,
|
|
62
|
+
createdAt,
|
|
63
|
+
updatedAt: createdAt,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const assets = attachments
|
|
68
|
+
.map((attachment, ordinal) => ({
|
|
69
|
+
ordinal,
|
|
70
|
+
uploadId: String(attachment?.sha256 || '').trim().toLowerCase(),
|
|
71
|
+
contentSha256: String(attachment?.sha256 || '').trim().toLowerCase(),
|
|
72
|
+
mediaType: String(attachment?.type || '').trim().toLowerCase(),
|
|
73
|
+
}))
|
|
74
|
+
.filter((asset) => asset.contentSha256 && asset.mediaType.startsWith('image/'));
|
|
75
|
+
const immutableIntent = {
|
|
76
|
+
conversationId,
|
|
77
|
+
sourceMessageId,
|
|
78
|
+
queueMessageId,
|
|
79
|
+
kind,
|
|
80
|
+
parentNodeId,
|
|
81
|
+
prompt: String(prompt || ''),
|
|
82
|
+
selectedImageModel,
|
|
83
|
+
orchestrationModel,
|
|
84
|
+
provider: 'openai',
|
|
85
|
+
executionMode,
|
|
86
|
+
parameters,
|
|
87
|
+
assets: assets.map(({ contentSha256, mediaType }) => ({ contentSha256, mediaType })),
|
|
88
|
+
replacesOperationId,
|
|
89
|
+
};
|
|
90
|
+
repository.insertOperation.run({
|
|
91
|
+
id: operationId,
|
|
92
|
+
imageSessionId,
|
|
93
|
+
sourceMessageId,
|
|
94
|
+
queueMessageId,
|
|
95
|
+
kind,
|
|
96
|
+
parentNodeId,
|
|
97
|
+
prompt: String(prompt || ''),
|
|
98
|
+
selectedImageModel,
|
|
99
|
+
orchestrationModel,
|
|
100
|
+
provider: 'openai',
|
|
101
|
+
executionMode,
|
|
102
|
+
parametersJson: canonicalJson(parameters),
|
|
103
|
+
requestFingerprint: sha256(canonicalJson(immutableIntent)),
|
|
104
|
+
idempotencyKey: operationId,
|
|
105
|
+
replacesOperationId,
|
|
106
|
+
createdAt,
|
|
107
|
+
});
|
|
108
|
+
for (const asset of assets) {
|
|
109
|
+
repository.insertAsset.run({
|
|
110
|
+
operationId,
|
|
111
|
+
ordinal: asset.ordinal,
|
|
112
|
+
role: 'reference',
|
|
113
|
+
uploadId: asset.uploadId,
|
|
114
|
+
contentSha256: asset.contentSha256,
|
|
115
|
+
mediaType: asset.mediaType,
|
|
116
|
+
createdAt,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
repository.linkQueueOperation.run(operationId, queueMessageId);
|
|
120
|
+
repository.touchSession.run(createdAt, imageSessionId);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
id: operationId,
|
|
124
|
+
imageSessionId,
|
|
125
|
+
kind,
|
|
126
|
+
parentNodeId,
|
|
127
|
+
mode: executionMode,
|
|
128
|
+
reconstructionMode,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
createEnqueuedOperation,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Keyed async mutex
|
|
3
|
+
//
|
|
4
|
+
// Node is single-threaded but not single-tasked: any handler that awaits can be
|
|
5
|
+
// interleaved by another request. Anything that reads state, mutates processes
|
|
6
|
+
// and writes state back needs to hold a lock across those awaits.
|
|
7
|
+
//
|
|
8
|
+
// Generic and dependency-free so it can be unit-tested and reused.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
const DEFAULT_STALE_AFTER_MS = 120_000;
|
|
12
|
+
|
|
13
|
+
export function createKeyedMutex({
|
|
14
|
+
nowMs = Date.now,
|
|
15
|
+
staleAfterMs = DEFAULT_STALE_AFTER_MS,
|
|
16
|
+
warn = console.warn,
|
|
17
|
+
} = {}) {
|
|
18
|
+
/** @type {Map<string, { tail: Promise<unknown>, depth: number, acquiredAtMs: number }>} */
|
|
19
|
+
const entries = new Map();
|
|
20
|
+
|
|
21
|
+
function warnIfStale(key, entry) {
|
|
22
|
+
if (!entry || !staleAfterMs) return;
|
|
23
|
+
const heldForMs = nowMs() - entry.acquiredAtMs;
|
|
24
|
+
if (heldForMs > staleAfterMs) {
|
|
25
|
+
warn(`[keyed-mutex] "${key}" has been held for ${heldForMs}ms`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function acquire(key) {
|
|
30
|
+
const existing = entries.get(key);
|
|
31
|
+
if (existing) {
|
|
32
|
+
warnIfStale(key, existing);
|
|
33
|
+
existing.depth += 1;
|
|
34
|
+
return existing;
|
|
35
|
+
}
|
|
36
|
+
const created = { tail: Promise.resolve(), depth: 1, acquiredAtMs: nowMs() };
|
|
37
|
+
entries.set(key, created);
|
|
38
|
+
return created;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function release(key, entry) {
|
|
42
|
+
entry.depth -= 1;
|
|
43
|
+
if (entry.depth <= 0 && entries.get(key) === entry) entries.delete(key);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function runExclusive(key, fn) {
|
|
47
|
+
const lockKey = String(key || '');
|
|
48
|
+
const entry = acquire(lockKey);
|
|
49
|
+
const previous = entry.tail;
|
|
50
|
+
let releaseTurn;
|
|
51
|
+
entry.tail = new Promise((resolve) => { releaseTurn = resolve; });
|
|
52
|
+
try {
|
|
53
|
+
await previous;
|
|
54
|
+
entry.acquiredAtMs = nowMs();
|
|
55
|
+
return await fn();
|
|
56
|
+
} finally {
|
|
57
|
+
releaseTurn();
|
|
58
|
+
release(lockKey, entry);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function tryRunExclusive(key, fn) {
|
|
63
|
+
const lockKey = String(key || '');
|
|
64
|
+
if (entries.has(lockKey)) {
|
|
65
|
+
warnIfStale(lockKey, entries.get(lockKey));
|
|
66
|
+
return { ok: false, busy: true, result: undefined };
|
|
67
|
+
}
|
|
68
|
+
const result = await runExclusive(lockKey, fn);
|
|
69
|
+
return { ok: true, busy: false, result };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
runExclusive,
|
|
74
|
+
tryRunExclusive,
|
|
75
|
+
isLocked: (key) => entries.has(String(key || '')),
|
|
76
|
+
size: () => entries.size,
|
|
77
|
+
stats: () => ({
|
|
78
|
+
held: entries.size,
|
|
79
|
+
waiting: [...entries.values()].reduce((total, entry) => total + Math.max(0, entry.depth - 1), 0),
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Magic-byte sniffing for uploads.
|
|
2
|
+
//
|
|
3
|
+
// `/api/upload` receives the MIME type from a client-controlled header, so an
|
|
4
|
+
// executable can claim to be `image/png`. Sniffing the real content type lets the
|
|
5
|
+
// stored metadata reflect the bytes rather than the claim.
|
|
6
|
+
|
|
7
|
+
const TEXTUAL_CLAIM_PATTERN = /^(text\/|application\/(json|xml|javascript|x-yaml|yaml)|image\/svg\+xml)/;
|
|
8
|
+
|
|
9
|
+
function startsWith(buffer, bytes, offset = 0) {
|
|
10
|
+
if (!buffer || buffer.length < offset + bytes.length) return false;
|
|
11
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
12
|
+
if (buffer[offset + i] !== bytes[i]) return false;
|
|
13
|
+
}
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function asciiAt(buffer, offset, length) {
|
|
18
|
+
if (!buffer || buffer.length < offset + length) return '';
|
|
19
|
+
return buffer.toString('latin1', offset, offset + length);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FTYP_BRANDS = new Map([
|
|
23
|
+
['avif', 'image/avif'],
|
|
24
|
+
['avis', 'image/avif'],
|
|
25
|
+
['heic', 'image/heic'],
|
|
26
|
+
['heix', 'image/heic'],
|
|
27
|
+
['hevc', 'image/heic'],
|
|
28
|
+
['heim', 'image/heic'],
|
|
29
|
+
['mif1', 'image/heif'],
|
|
30
|
+
['msf1', 'image/heif'],
|
|
31
|
+
['qt ', 'video/quicktime'],
|
|
32
|
+
['isom', 'video/mp4'],
|
|
33
|
+
['iso2', 'video/mp4'],
|
|
34
|
+
['mp41', 'video/mp4'],
|
|
35
|
+
['mp42', 'video/mp4'],
|
|
36
|
+
['M4V ', 'video/x-m4v'],
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Returns the detected MIME type, or '' when the bytes are not recognised.
|
|
41
|
+
* Absence of a signature is not evidence of anything: plain text, CSV and source
|
|
42
|
+
* files legitimately have no magic bytes.
|
|
43
|
+
*/
|
|
44
|
+
export function sniffMimeType(buffer) {
|
|
45
|
+
if (!Buffer.isBuffer(buffer) || !buffer.length) return '';
|
|
46
|
+
|
|
47
|
+
if (startsWith(buffer, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png';
|
|
48
|
+
if (startsWith(buffer, [0xff, 0xd8, 0xff])) return 'image/jpeg';
|
|
49
|
+
if (startsWith(buffer, [0x47, 0x49, 0x46, 0x38])) return 'image/gif';
|
|
50
|
+
if (startsWith(buffer, [0x42, 0x4d])) return 'image/bmp';
|
|
51
|
+
if (startsWith(buffer, [0x00, 0x00, 0x01, 0x00])) return 'image/x-icon';
|
|
52
|
+
|
|
53
|
+
if (asciiAt(buffer, 0, 4) === 'RIFF') {
|
|
54
|
+
const form = asciiAt(buffer, 8, 4);
|
|
55
|
+
if (form === 'WEBP') return 'image/webp';
|
|
56
|
+
if (form === 'WAVE') return 'audio/wav';
|
|
57
|
+
if (form === 'AVI ') return 'video/x-msvideo';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (asciiAt(buffer, 4, 4) === 'ftyp') {
|
|
61
|
+
const brand = asciiAt(buffer, 8, 4);
|
|
62
|
+
if (FTYP_BRANDS.has(brand)) return FTYP_BRANDS.get(brand);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (startsWith(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d])) return 'application/pdf';
|
|
66
|
+
if (startsWith(buffer, [0x50, 0x4b, 0x03, 0x04])
|
|
67
|
+
|| startsWith(buffer, [0x50, 0x4b, 0x05, 0x06])
|
|
68
|
+
|| startsWith(buffer, [0x50, 0x4b, 0x07, 0x08])) return 'application/zip';
|
|
69
|
+
if (startsWith(buffer, [0x1f, 0x8b])) return 'application/gzip';
|
|
70
|
+
if (startsWith(buffer, [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c])) return 'application/x-7z-compressed';
|
|
71
|
+
if (startsWith(buffer, [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07])) return 'application/vnd.rar';
|
|
72
|
+
|
|
73
|
+
// Executables. These are the reason sniffing exists at all.
|
|
74
|
+
if (startsWith(buffer, [0x4d, 0x5a])) return 'application/vnd.microsoft.portable-executable';
|
|
75
|
+
if (startsWith(buffer, [0x7f, 0x45, 0x4c, 0x46])) return 'application/x-elf';
|
|
76
|
+
if (startsWith(buffer, [0xca, 0xfe, 0xba, 0xbe])
|
|
77
|
+
|| startsWith(buffer, [0xcf, 0xfa, 0xed, 0xfe])
|
|
78
|
+
|| startsWith(buffer, [0xce, 0xfa, 0xed, 0xfe])) return 'application/x-mach-binary';
|
|
79
|
+
if (startsWith(buffer, [0x23, 0x21])) return 'text/x-shellscript';
|
|
80
|
+
|
|
81
|
+
return '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function normalizeClaimedMimeType(value) {
|
|
85
|
+
return String(value || '').trim().toLowerCase().split(';')[0].slice(0, 127);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reconciles the client's claim against the sniffed reality. The sniffed type
|
|
90
|
+
* wins on disagreement so a disguised executable is stored as what it is, while
|
|
91
|
+
* unrecognised bytes leave the claim untouched.
|
|
92
|
+
*/
|
|
93
|
+
export function reconcileMimeType(claimed, sniffed) {
|
|
94
|
+
const claim = normalizeClaimedMimeType(claimed) || 'application/octet-stream';
|
|
95
|
+
const detected = normalizeClaimedMimeType(sniffed);
|
|
96
|
+
|
|
97
|
+
if (!detected) return { mimeType: claim, corrected: false, claimed: claim, sniffed: '' };
|
|
98
|
+
if (detected === claim) return { mimeType: claim, corrected: false, claimed: claim, sniffed: detected };
|
|
99
|
+
|
|
100
|
+
// JPEG has two spellings in the wild; treat them as agreement.
|
|
101
|
+
if ((claim === 'image/jpg' && detected === 'image/jpeg') || (claim === 'image/jpeg' && detected === 'image/jpg')) {
|
|
102
|
+
return { mimeType: 'image/jpeg', corrected: false, claimed: claim, sniffed: detected };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Office/OpenDocument files and JARs are ZIP containers; the specific claim is
|
|
106
|
+
// more informative than the generic container type.
|
|
107
|
+
if (detected === 'application/zip' && claim !== 'application/octet-stream' && TEXTUAL_CLAIM_PATTERN.test(claim) === false) {
|
|
108
|
+
if (claim.includes('officedocument') || claim.includes('opendocument') || claim.includes('epub') || claim.includes('java-archive')) {
|
|
109
|
+
return { mimeType: claim, corrected: false, claimed: claim, sniffed: detected };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { mimeType: detected, corrected: true, claimed: claim, sniffed: detected };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function resolveUploadMimeType(buffer, claimedType) {
|
|
117
|
+
return reconcileMimeType(claimedType, sniffMimeType(buffer));
|
|
118
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers behind `oar setup` and `oar doctor` — everything here is
|
|
3
|
+
* deterministic and unit-testable; the interactive glue stays in bin/oar.js.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export function generateAuthToken() {
|
|
11
|
+
return randomBytes(32).toString('base64url');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildDefaultConfig({ token = generateAuthToken(), port = 3333, localhostOnly = true } = {}) {
|
|
15
|
+
return {
|
|
16
|
+
authToken: token,
|
|
17
|
+
port,
|
|
18
|
+
localhostOnly,
|
|
19
|
+
pollIntervalMs: 3000,
|
|
20
|
+
processingTimeoutMs: 600000,
|
|
21
|
+
conversationSessionMode: 'isolated',
|
|
22
|
+
updateCheck: true,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** First non-internal IPv4 address, for the phone-facing URL when LAN access is on. */
|
|
27
|
+
export function primaryLanAddress(interfaces = os.networkInterfaces()) {
|
|
28
|
+
for (const entries of Object.values(interfaces)) {
|
|
29
|
+
for (const entry of entries || []) {
|
|
30
|
+
if (entry && entry.family === 'IPv4' && !entry.internal) return entry.address;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function relayUrl({ config, lanAddress = null } = {}) {
|
|
37
|
+
const host = !config?.localhostOnly && lanAddress ? lanAddress : 'localhost';
|
|
38
|
+
const token = String(config?.authToken || '').trim();
|
|
39
|
+
return `http://${host}:${config?.port || 3333}/?token=${encodeURIComponent(token)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A systemd *user* unit for the relay server. The launcher is not used here —
|
|
44
|
+
* the service runs server.js directly with the same env the launcher would set,
|
|
45
|
+
* so a `gh copilot` session is not tied to the unit's lifetime.
|
|
46
|
+
*/
|
|
47
|
+
export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, logDir }) {
|
|
48
|
+
return [
|
|
49
|
+
'[Unit]',
|
|
50
|
+
'Description=OAR — Open Agent Relay',
|
|
51
|
+
'After=network-online.target',
|
|
52
|
+
'',
|
|
53
|
+
'[Service]',
|
|
54
|
+
`ExecStart=${nodeBin} ${path.join(packageRoot, 'server', 'server.js')}`,
|
|
55
|
+
`Environment=COPILOT_WEB_RELAY_CONFIG=${configPath}`,
|
|
56
|
+
`Environment=COPILOT_WEB_RELAY_DATA_DIR=${dataDir}`,
|
|
57
|
+
`Environment=COPILOT_WEB_RELAY_LOG_DIR=${logDir}`,
|
|
58
|
+
'Restart=on-failure',
|
|
59
|
+
'RestartSec=5',
|
|
60
|
+
'',
|
|
61
|
+
'[Install]',
|
|
62
|
+
'WantedBy=default.target',
|
|
63
|
+
'',
|
|
64
|
+
].join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Provider CLIs `oar doctor` probes, with the args that answer fast. Cursor is
|
|
69
|
+
* deliberately absent: the relay drives it through the bundled @cursor/sdk npm
|
|
70
|
+
* package and never invokes a cursor-agent binary.
|
|
71
|
+
*/
|
|
72
|
+
export const DOCTOR_PROBES = Object.freeze([
|
|
73
|
+
{ id: 'gh (Copilot)', binary: 'gh', args: ['--version'] },
|
|
74
|
+
{ id: 'claude', binary: 'claude', args: ['--version'] },
|
|
75
|
+
{ id: 'grok', binary: 'grok', args: ['--version'] },
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
export function renderDoctorReport({
|
|
79
|
+
version,
|
|
80
|
+
nodeVersion,
|
|
81
|
+
platform,
|
|
82
|
+
layout,
|
|
83
|
+
configPath,
|
|
84
|
+
config,
|
|
85
|
+
dbPath,
|
|
86
|
+
dbSizeBytes,
|
|
87
|
+
probes = [],
|
|
88
|
+
}) {
|
|
89
|
+
const yesNo = (v) => (v ? 'yes' : 'no');
|
|
90
|
+
const lines = [
|
|
91
|
+
`OAR ${version}`,
|
|
92
|
+
` node : ${nodeVersion} (${platform})`,
|
|
93
|
+
` mode : ${layout?.checkout ? 'git checkout' : 'global install'}`,
|
|
94
|
+
` state root : ${layout?.checkout ? '(repo-local server/)' : layout?.root}`,
|
|
95
|
+
` config : ${configPath}${config ? '' : ' (missing — run: oar setup)'}`,
|
|
96
|
+
];
|
|
97
|
+
if (config) {
|
|
98
|
+
lines.push(` port : ${config.port ?? 3333} (localhostOnly: ${yesNo(config.localhostOnly !== false)})`);
|
|
99
|
+
lines.push(` auth token : ${String(config.authToken || '').trim() ? 'set' : 'MISSING'}`);
|
|
100
|
+
const tunnel = config.cloudflaredTunnel || {};
|
|
101
|
+
lines.push(` tunnel : ${tunnel.enabled === true || tunnel.mode === 'managed' ? 'managed' : 'disabled'}`);
|
|
102
|
+
}
|
|
103
|
+
lines.push(` database : ${dbSizeBytes !== null ? `${dbPath} (${(dbSizeBytes / 1048576).toFixed(1)} MB)` : `${dbPath} (not created yet)`}`);
|
|
104
|
+
lines.push(' provider CLIs :');
|
|
105
|
+
for (const probe of probes) {
|
|
106
|
+
lines.push(` ${probe.id.padEnd(14)}: ${probe.ok ? probe.version || 'installed' : 'not found'}`);
|
|
107
|
+
}
|
|
108
|
+
return lines.join('\n');
|
|
109
|
+
}
|