talos-code 0.0.1 → 0.2.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/CHANGELOG.md +87 -0
- package/LICENSE +661 -0
- package/README.md +116 -3
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/dist/archive/zip.js +73 -0
- package/dist/args.js +100 -0
- package/dist/automations/history-store.js +34 -0
- package/dist/automations/policy-store.js +43 -0
- package/dist/automations/runner.js +107 -0
- package/dist/automations/schedule.js +85 -0
- package/dist/automations/windows-task.js +53 -0
- package/dist/commands/advanced-cli.js +233 -0
- package/dist/commands/checkpoint-cli.js +45 -0
- package/dist/commands/config-cli.js +107 -0
- package/dist/commands/context.js +17 -0
- package/dist/commands/extensions-cli.js +207 -0
- package/dist/commands/project-cli.js +74 -0
- package/dist/commands/project-commands.js +143 -0
- package/dist/commands/provider-cli.js +244 -0
- package/dist/commands/services-cli.js +248 -0
- package/dist/commands/session-cli.js +171 -0
- package/dist/commands/system-cli.js +405 -0
- package/dist/config/commands.js +60 -0
- package/dist/config/load.js +382 -0
- package/dist/config/migrations.js +48 -0
- package/dist/config/types.js +11 -0
- package/dist/diagnostics/development-log.js +154 -0
- package/dist/diagnostics/doctor.js +104 -0
- package/dist/diagnostics/redact.js +80 -0
- package/dist/diagnostics/zip.js +52 -0
- package/dist/errors.js +156 -0
- package/dist/events/bridge.js +130 -0
- package/dist/extensions/installer.js +178 -0
- package/dist/extensions/package-schema.js +24 -0
- package/dist/headless/result.js +84 -0
- package/dist/headless/run.js +230 -0
- package/dist/i18n/en/approval.js +46 -0
- package/dist/i18n/en/common.js +9 -0
- package/dist/i18n/en/credentials.js +92 -0
- package/dist/i18n/en/errors.js +267 -0
- package/dist/i18n/en/firstrun.js +101 -0
- package/dist/i18n/en/providers.js +37 -0
- package/dist/i18n/en/screen.js +774 -0
- package/dist/i18n/en/tools.js +127 -0
- package/dist/i18n/en.js +14 -0
- package/dist/i18n/error-view.js +307 -0
- package/dist/i18n/index.js +19 -0
- package/dist/i18n/kernel-map.js +139 -0
- package/dist/io.js +45 -0
- package/dist/main.js +331 -0
- package/dist/paths.js +41 -0
- package/dist/protocol/v2/codec.js +9 -0
- package/dist/protocol/v2/events.js +555 -0
- package/dist/protocol/v2/index.js +4 -0
- package/dist/protocol/v2/replay.js +28 -0
- package/dist/protocol/v2/types.js +1 -0
- package/dist/provider/control-plane.js +135 -0
- package/dist/provider/environment-keys.js +275 -0
- package/dist/provider/health.js +110 -0
- package/dist/provider/missing-key.js +48 -0
- package/dist/provider/model-catalog.js +168 -0
- package/dist/provider/openrouter-login.js +172 -0
- package/dist/provider/provider-text.js +13 -0
- package/dist/provider/store.js +95 -0
- package/dist/provider/system-keyring.js +214 -0
- package/dist/runtime/active-run.js +63 -0
- package/dist/runtime/agent-tree.js +74 -0
- package/dist/runtime/attachments.js +84 -0
- package/dist/runtime/brokered-executor.js +395 -0
- package/dist/runtime/context-archive.js +69 -0
- package/dist/runtime/context-status.js +76 -0
- package/dist/runtime/create-runtime.js +111 -0
- package/dist/runtime/model-profile.js +361 -0
- package/dist/runtime/output-store.js +137 -0
- package/dist/runtime/provider-attempts.js +185 -0
- package/dist/runtime/replay-buffer.js +153 -0
- package/dist/runtime/repo.js +95 -0
- package/dist/runtime/session-facade.js +135 -0
- package/dist/runtime/session-summary.js +125 -0
- package/dist/runtime/supervisor.js +265 -0
- package/dist/runtime/talos-composition.js +1011 -0
- package/dist/runtime/types.js +5 -0
- package/dist/runtime/usage-snapshot.js +82 -0
- package/dist/security/auto-classifier.js +38 -0
- package/dist/security/credential-free-environment.js +54 -0
- package/dist/security/evaluate.js +243 -0
- package/dist/security/execution-backends.js +236 -0
- package/dist/security/execution-broker.js +102 -0
- package/dist/security/forge-scan.js +74 -0
- package/dist/security/from-approval.js +39 -0
- package/dist/security/mxc-execution-backend.js +281 -0
- package/dist/security/permission-engine.js +138 -0
- package/dist/security/permission-explanation.js +54 -0
- package/dist/security/persist.js +179 -0
- package/dist/security/plugin-guard.js +230 -0
- package/dist/security/process-tree-evidence-store.js +74 -0
- package/dist/security/process-tree-probe.js +554 -0
- package/dist/security/project-resource-inventory.js +186 -0
- package/dist/security/project-trust-gate.js +38 -0
- package/dist/security/project-trust.js +367 -0
- package/dist/security/rule-parser.js +201 -0
- package/dist/security/shell-segmentation.js +68 -0
- package/dist/security/trust-authority.js +324 -0
- package/dist/security/types.js +1 -0
- package/dist/security/workspace-identity.js +102 -0
- package/dist/services/automation-facade.js +59 -0
- package/dist/services/forge-facade.js +77 -0
- package/dist/services/hook-facade.js +240 -0
- package/dist/services/index.js +16 -0
- package/dist/services/library-facade.js +174 -0
- package/dist/services/mcp-facade.js +348 -0
- package/dist/services/memory-facade.js +126 -0
- package/dist/services/notes-facade.js +157 -0
- package/dist/services/plugin-facade.js +308 -0
- package/dist/services/research-facade.js +110 -0
- package/dist/services/task-board-facade.js +236 -0
- package/dist/services/workflow-catalog.js +246 -0
- package/dist/sessions/export-format.js +98 -0
- package/dist/sessions/metadata-store.js +160 -0
- package/dist/sessions/share.js +122 -0
- package/dist/sessions/transfer.js +16 -0
- package/dist/subcommands.js +62 -0
- package/dist/tui/agent-roster.js +36 -0
- package/dist/tui/agent-view.js +51 -0
- package/dist/tui/app.js +4098 -0
- package/dist/tui/approval.js +36 -0
- package/dist/tui/boot/boot-sequence.js +76 -0
- package/dist/tui/boot/cinematic.js +243 -0
- package/dist/tui/boot/desktop-logo.js +82 -0
- package/dist/tui/boot.js +3 -0
- package/dist/tui/busy-input.js +58 -0
- package/dist/tui/catalog-service.js +560 -0
- package/dist/tui/components/assistant-stream.js +1 -0
- package/dist/tui/components/command-menu.js +68 -0
- package/dist/tui/components/composer.js +219 -0
- package/dist/tui/components/diff.js +35 -0
- package/dist/tui/components/footer.js +48 -0
- package/dist/tui/components/header.js +6 -0
- package/dist/tui/components/markdown.js +305 -0
- package/dist/tui/components/status-indicator.js +38 -0
- package/dist/tui/components/terminal-shell.js +214 -0
- package/dist/tui/components/thinking-row.js +19 -0
- package/dist/tui/components/tool-row.js +97 -0
- package/dist/tui/components/transcript-virtualizer.js +165 -0
- package/dist/tui/components/transcript.js +43 -0
- package/dist/tui/descendant-approvals.js +104 -0
- package/dist/tui/diff-model.js +77 -0
- package/dist/tui/editor-history.js +21 -0
- package/dist/tui/editor.js +210 -0
- package/dist/tui/event-adapter.js +436 -0
- package/dist/tui/exit-output.js +58 -0
- package/dist/tui/external-editor.js +53 -0
- package/dist/tui/file-completion.js +89 -0
- package/dist/tui/focus-manager.js +5 -0
- package/dist/tui/highlight.js +30 -0
- package/dist/tui/input-router.js +18 -0
- package/dist/tui/interrupt.js +99 -0
- package/dist/tui/keybindings.js +194 -0
- package/dist/tui/keymap-resolver.js +91 -0
- package/dist/tui/launch-state.js +148 -0
- package/dist/tui/line-diff.js +57 -0
- package/dist/tui/live-activity.js +56 -0
- package/dist/tui/metrics.js +123 -0
- package/dist/tui/onboarding.js +36 -0
- package/dist/tui/overlays/agent-tree.js +43 -0
- package/dist/tui/overlays/approval-dialog.js +642 -0
- package/dist/tui/overlays/automation-center.js +51 -0
- package/dist/tui/overlays/checkpoint-picker.js +47 -0
- package/dist/tui/overlays/context-inspector.js +36 -0
- package/dist/tui/overlays/effort-line.js +110 -0
- package/dist/tui/overlays/forge-center.js +46 -0
- package/dist/tui/overlays/help-dialog.js +16 -0
- package/dist/tui/overlays/history-picker.js +37 -0
- package/dist/tui/overlays/hook-center.js +70 -0
- package/dist/tui/overlays/library-center.js +55 -0
- package/dist/tui/overlays/mcp-center.js +60 -0
- package/dist/tui/overlays/memory-center.js +63 -0
- package/dist/tui/overlays/model-picker.js +77 -0
- package/dist/tui/overlays/notes-center.js +53 -0
- package/dist/tui/overlays/overlay-host.js +8 -0
- package/dist/tui/overlays/plugin-center.js +76 -0
- package/dist/tui/overlays/provider-picker.js +171 -0
- package/dist/tui/overlays/queue-editor.js +32 -0
- package/dist/tui/overlays/research-center.js +59 -0
- package/dist/tui/overlays/scroll-window.js +234 -0
- package/dist/tui/overlays/session-picker.js +115 -0
- package/dist/tui/overlays/tasks-center.js +83 -0
- package/dist/tui/overlays/theme-picker.js +21 -0
- package/dist/tui/overlays/transcript-search.js +58 -0
- package/dist/tui/overlays/trust-center.js +29 -0
- package/dist/tui/overlays/workflow-center.js +46 -0
- package/dist/tui/project-file-index.js +214 -0
- package/dist/tui/project-references.js +85 -0
- package/dist/tui/project-trust-prompt.js +289 -0
- package/dist/tui/prompt-history-store.js +116 -0
- package/dist/tui/queue-store.js +110 -0
- package/dist/tui/regions/budget.js +59 -0
- package/dist/tui/regions/views.js +96 -0
- package/dist/tui/render-coordinator.js +148 -0
- package/dist/tui/render-scheduler.js +4 -0
- package/dist/tui/run.js +69 -0
- package/dist/tui/selection-list.js +29 -0
- package/dist/tui/session-controller.js +791 -0
- package/dist/tui/session-export.js +54 -0
- package/dist/tui/setup-wizard.js +46 -0
- package/dist/tui/shell-input.js +35 -0
- package/dist/tui/shell-layout.js +48 -0
- package/dist/tui/shell-model.js +98 -0
- package/dist/tui/slash-commands.js +82 -0
- package/dist/tui/state.js +151 -0
- package/dist/tui/status-bar.js +125 -0
- package/dist/tui/status-view.js +40 -0
- package/dist/tui/terminal-capabilities.js +9 -0
- package/dist/tui/terminal-session.js +11 -0
- package/dist/tui/text-width.js +66 -0
- package/dist/tui/theme-catalog.js +27 -0
- package/dist/tui/theme-store.js +23 -0
- package/dist/tui/theme.js +23 -0
- package/dist/tui/tool-display.js +135 -0
- package/dist/tui/tool-facts.js +1 -0
- package/dist/tui/tools/bash-renderer.js +32 -0
- package/dist/tui/tools/change.js +61 -0
- package/dist/tui/tools/edit-renderer.js +17 -0
- package/dist/tui/tools/generic-renderer.js +13 -0
- package/dist/tui/tools/list-renderer.js +14 -0
- package/dist/tui/tools/read-renderer.js +13 -0
- package/dist/tui/tools/registry.js +20 -0
- package/dist/tui/tools/row-format.js +187 -0
- package/dist/tui/tools/search-renderer.js +38 -0
- package/dist/tui/tools/shared.js +97 -0
- package/dist/tui/tools/write-renderer.js +15 -0
- package/dist/tui/transcript-model.js +441 -0
- package/dist/tui/ui-preferences.js +79 -0
- package/dist/tui/usage-view.js +77 -0
- package/dist/tui/vim-mode.js +99 -0
- package/dist/update/check.js +15 -0
- package/dist/update/npm.js +51 -0
- package/dist/update/run.js +129 -0
- package/dist/version.js +2 -0
- package/dist/workspace/checkpoint-store.js +210 -0
- package/dist/workspace/checkpoint.js +413 -0
- package/dist/workspace/restore.js +232 -0
- package/package.json +64 -5
- package/vendor/context-engine/package.json +11 -0
- package/vendor/context-engine/src/compaction-planner.mjs +57 -0
- package/vendor/context-engine/src/contracts.mjs +48 -0
- package/vendor/context-engine/src/engine.mjs +445 -0
- package/vendor/context-engine/src/node/context-export.mjs +164 -0
- package/vendor/context-engine/src/node/legacy-import.mjs +92 -0
- package/vendor/context-engine/src/node/migrations/001-context.sql +126 -0
- package/vendor/context-engine/src/node/sqlite-store.mjs +85 -0
- package/vendor/context-engine/src/node/sqlite-worker.mjs +559 -0
- package/vendor/context-engine/src/profiles.mjs +10 -0
- package/vendor/context-engine/src/retrieval.mjs +104 -0
- package/vendor/context-engine/src/summary.mjs +97 -0
- package/vendor/context-engine/src/usage.mjs +34 -0
- package/vendor/harness-ui/package.json +38 -0
- package/vendor/harness-ui/src/acp-agent.mjs +350 -0
- package/vendor/harness-ui/src/agent-service.mjs +2188 -0
- package/vendor/harness-ui/src/agui-events.mjs +452 -0
- package/vendor/harness-ui/src/ambiente-solo-server.mjs +121 -0
- package/vendor/harness-ui/src/artifact-store.mjs +39 -0
- package/vendor/harness-ui/src/assistenza.mjs +123 -0
- package/vendor/harness-ui/src/automation-scheduler.mjs +69 -0
- package/vendor/harness-ui/src/automation-store.mjs +145 -0
- package/vendor/harness-ui/src/browser-annota.mjs +639 -0
- package/vendor/harness-ui/src/browser-frame.mjs +211 -0
- package/vendor/harness-ui/src/browser-proxy-universale.mjs +519 -0
- package/vendor/harness-ui/src/browser-proxy.mjs +87 -0
- package/vendor/harness-ui/src/browser-sessione-viva.mjs +329 -0
- package/vendor/harness-ui/src/browser-stream.mjs +445 -0
- package/vendor/harness-ui/src/browser-vivo.mjs +694 -0
- package/vendor/harness-ui/src/chat-image-attachments.mjs +72 -0
- package/vendor/harness-ui/src/config.mjs +697 -0
- package/vendor/harness-ui/src/contesto-del-progetto.mjs +385 -0
- package/vendor/harness-ui/src/context-asset-adapter.mjs +72 -0
- package/vendor/harness-ui/src/context-desktop-service.mjs +253 -0
- package/vendor/harness-ui/src/context-embedding-runtime.mjs +252 -0
- package/vendor/harness-ui/src/context-inference-scheduler.mjs +81 -0
- package/vendor/harness-ui/src/context-native-compaction.mjs +75 -0
- package/vendor/harness-ui/src/context-provider-adapter.mjs +141 -0
- package/vendor/harness-ui/src/context-runtime.mjs +118 -0
- package/vendor/harness-ui/src/context-token-counters.mjs +184 -0
- package/vendor/harness-ui/src/context-tool-catalog.mjs +86 -0
- package/vendor/harness-ui/src/context-tool-output.mjs +72 -0
- package/vendor/harness-ui/src/costo-elenco.mjs +252 -0
- package/vendor/harness-ui/src/custom-task.mjs +171 -0
- package/vendor/harness-ui/src/doctor.mjs +142 -0
- package/vendor/harness-ui/src/document-filename.mjs +97 -0
- package/vendor/harness-ui/src/document-generator.mjs +493 -0
- package/vendor/harness-ui/src/document-report.mjs +331 -0
- package/vendor/harness-ui/src/duckduckgo-search.mjs +155 -0
- package/vendor/harness-ui/src/elenco-profondo.mjs +337 -0
- package/vendor/harness-ui/src/favicon-proxy.mjs +113 -0
- package/vendor/harness-ui/src/forge-contract.mjs +221 -0
- package/vendor/harness-ui/src/frequent-dirs.mjs +134 -0
- package/vendor/harness-ui/src/generated-image-store.mjs +147 -0
- package/vendor/harness-ui/src/generation-idle.mjs +332 -0
- package/vendor/harness-ui/src/gguf-header.mjs +207 -0
- package/vendor/harness-ui/src/git-service.mjs +626 -0
- package/vendor/harness-ui/src/gitignore-elenco.mjs +604 -0
- package/vendor/harness-ui/src/harness-receipt-keypair.mjs +207 -0
- package/vendor/harness-ui/src/hf-direct-transfer.mjs +170 -0
- package/vendor/harness-ui/src/hf-hub-client.mjs +106 -0
- package/vendor/harness-ui/src/hf-image-proxy.mjs +105 -0
- package/vendor/harness-ui/src/hf-model-transfer.mjs +245 -0
- package/vendor/harness-ui/src/hook-registry.mjs +186 -0
- package/vendor/harness-ui/src/http-app.mjs +6259 -0
- package/vendor/harness-ui/src/http-lifecycle.mjs +132 -0
- package/vendor/harness-ui/src/id-archivio.mjs +27 -0
- package/vendor/harness-ui/src/image-generator.mjs +143 -0
- package/vendor/harness-ui/src/istruzioni-di-progetto.mjs +234 -0
- package/vendor/harness-ui/src/kernel/dist/kernelPerIlBanco.js +518 -0
- package/vendor/harness-ui/src/kernel/talosHarness.mjs +10437 -0
- package/vendor/harness-ui/src/library-policy-store.mjs +175 -0
- package/vendor/harness-ui/src/library-store.mjs +652 -0
- package/vendor/harness-ui/src/llama-server-supervisor.mjs +629 -0
- package/vendor/harness-ui/src/local-model-store.mjs +339 -0
- package/vendor/harness-ui/src/local-runtime-contract.mjs +66 -0
- package/vendor/harness-ui/src/local-runtime-events.mjs +44 -0
- package/vendor/harness-ui/src/local-runtime-llama-server.mjs +244 -0
- package/vendor/harness-ui/src/local-runtime-probe.mjs +401 -0
- package/vendor/harness-ui/src/machine-capacity.mjs +66 -0
- package/vendor/harness-ui/src/mappa-cartelle.mjs +491 -0
- package/vendor/harness-ui/src/mcp-client.mjs +98 -0
- package/vendor/harness-ui/src/mcp-registry.mjs +157 -0
- package/vendor/harness-ui/src/mcp-session.mjs +177 -0
- package/vendor/harness-ui/src/memory-store.mjs +227 -0
- package/vendor/harness-ui/src/model-catalog-models-dev.mjs +276 -0
- package/vendor/harness-ui/src/model-catalog.mjs +129 -0
- package/vendor/harness-ui/src/model-destination.mjs +189 -0
- package/vendor/harness-ui/src/modifica-ancorata.mjs +177 -0
- package/vendor/harness-ui/src/native-provider-adapter.mjs +205 -0
- package/vendor/harness-ui/src/notes-store.mjs +250 -0
- package/vendor/harness-ui/src/openai-compatible-runtime.mjs +428 -0
- package/vendor/harness-ui/src/openrouter-oauth.mjs +339 -0
- package/vendor/harness-ui/src/path-policy.mjs +442 -0
- package/vendor/harness-ui/src/plugin-registry.mjs +780 -0
- package/vendor/harness-ui/src/plugin-session.mjs +180 -0
- package/vendor/harness-ui/src/process-policy.mjs +345 -0
- package/vendor/harness-ui/src/prompt-enhancer-provider.mjs +94 -0
- package/vendor/harness-ui/src/provider-auth-cloud.mjs +95 -0
- package/vendor/harness-ui/src/provider-credential-store.mjs +541 -0
- package/vendor/harness-ui/src/provider-probe.mjs +582 -0
- package/vendor/harness-ui/src/provider-registry.mjs +1633 -0
- package/vendor/harness-ui/src/pty-terminal.mjs +312 -0
- package/vendor/harness-ui/src/public-problem.mjs +109 -0
- package/vendor/harness-ui/src/research/card.mjs +235 -0
- package/vendor/harness-ui/src/research/citations.mjs +142 -0
- package/vendor/harness-ui/src/research/collector.mjs +275 -0
- package/vendor/harness-ui/src/research/deposito-a-pezzi.mjs +139 -0
- package/vendor/harness-ui/src/research/dossier.mjs +114 -0
- package/vendor/harness-ui/src/research/esportazioni.mjs +560 -0
- package/vendor/harness-ui/src/research/fetch-cache.mjs +465 -0
- package/vendor/harness-ui/src/research/fidelity.mjs +122 -0
- package/vendor/harness-ui/src/research/independence.mjs +159 -0
- package/vendor/harness-ui/src/research/ledger.mjs +166 -0
- package/vendor/harness-ui/src/research/markdown-server.mjs +565 -0
- package/vendor/harness-ui/src/research/narration.mjs +181 -0
- package/vendor/harness-ui/src/research/open-cards.mjs +131 -0
- package/vendor/harness-ui/src/research/opposing.mjs +305 -0
- package/vendor/harness-ui/src/research/outline.mjs +111 -0
- package/vendor/harness-ui/src/research/page-budget.mjs +209 -0
- package/vendor/harness-ui/src/research/pdf.mjs +291 -0
- package/vendor/harness-ui/src/research/plan.mjs +301 -0
- package/vendor/harness-ui/src/research/raccolta-viva.mjs +452 -0
- package/vendor/harness-ui/src/research/recheck-document.mjs +69 -0
- package/vendor/harness-ui/src/research/recheck-history.mjs +192 -0
- package/vendor/harness-ui/src/research/recheck.mjs +194 -0
- package/vendor/harness-ui/src/research/report.mjs +203 -0
- package/vendor/harness-ui/src/research/run.mjs +527 -0
- package/vendor/harness-ui/src/research/synthesis.mjs +318 -0
- package/vendor/harness-ui/src/research/verification.mjs +572 -0
- package/vendor/harness-ui/src/research-orchestrator.mjs +2679 -0
- package/vendor/harness-ui/src/research-store.mjs +1133 -0
- package/vendor/harness-ui/src/runtime-build-manifest.mjs +26 -0
- package/vendor/harness-ui/src/runtime-contract.mjs +59 -0
- package/vendor/harness-ui/src/runtime-owner-adapter.mjs +1348 -0
- package/vendor/harness-ui/src/runtime-owner-contract.mjs +32 -0
- package/vendor/harness-ui/src/scheda-di-lavoro.mjs +249 -0
- package/vendor/harness-ui/src/search-source-store.mjs +172 -0
- package/vendor/harness-ui/src/session-registry.mjs +6095 -0
- package/vendor/harness-ui/src/session-store.mjs +220 -0
- package/vendor/harness-ui/src/sessione-pronta.mjs +73 -0
- package/vendor/harness-ui/src/setup-stato.mjs +31 -0
- package/vendor/harness-ui/src/sezioni-istruzioni.mjs +204 -0
- package/vendor/harness-ui/src/skill-registry.mjs +120 -0
- package/vendor/harness-ui/src/sse-replay-coalescente.mjs +0 -0
- package/vendor/harness-ui/src/static-files.mjs +96 -0
- package/vendor/harness-ui/src/stream-partition.mjs +123 -0
- package/vendor/harness-ui/src/subagent-orchestrator.mjs +453 -0
- package/vendor/harness-ui/src/task-catalog.mjs +65 -0
- package/vendor/harness-ui/src/tasks-store.mjs +220 -0
- package/vendor/harness-ui/src/terminal-registry.mjs +312 -0
- package/vendor/harness-ui/src/terminal-ws.mjs +170 -0
- package/vendor/harness-ui/src/tool-forge-store.mjs +299 -0
- package/vendor/harness-ui/src/tool-schema-normalize.mjs +100 -0
- package/vendor/harness-ui/src/usage-cache.mjs +315 -0
- package/vendor/harness-ui/src/workspace-browser.mjs +213 -0
- package/vendor/harness-ui/src/workspace-context.mjs +124 -0
- package/vendor/harness-ui/src/workspace-disk.mjs +62 -0
- package/vendor/harness-ui/src/workspace-files.mjs +589 -0
- package/vendor/harness-ui/src/workspace-info.mjs +189 -0
- package/vendor/harness-ui/src/workspace-launch-store.mjs +150 -0
- package/vendor/harness-ui/src/workspace-tree.mjs +67 -0
- package/vendor/harness-ui/src/workspace-watcher.mjs +161 -0
- package/vendor/manifest.json +170 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* harness-receipt-keypair.mjs — la chiave Ed25519 che firma le ricevute del
|
|
3
|
+
* kernel (`talosHarness.mjs`, `creaRicevutaOperazione`/`firma`).
|
|
4
|
+
*
|
|
5
|
+
* ⭐⭐⭐ 29/8, FASE D — il pezzo dichiarato aperto fin dal primo incremento
|
|
6
|
+
* della firma: "decidere dove vivono le chiavi e chi verifica: una
|
|
7
|
+
* decisione separata, non presa qui". Presa qui, ora.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ Stesso disegno di `control-plane/scripts/browser-action-keypair.mjs`
|
|
10
|
+
* (AVM, letto prima di scrivere questo file — REGOLA ZERO, cercato nel
|
|
11
|
+
* proprio codebase prima di inventare un pattern nuovo): provisioning
|
|
12
|
+
* persistito su un file `.env`, PEM codificato in base64, tre variabili
|
|
13
|
+
* (privata/pubblica/id). NON lo stesso algoritmo — quello è EC P-256 per
|
|
14
|
+
* un token JWT con scadenza; questo è Ed25519 per una ricevuta d'audit
|
|
15
|
+
* permanente, lo stesso RFC 8032 già usato da `generaChiaviFirmaRicevute()`
|
|
16
|
+
* nel kernel — e NON lo stesso file: due concetti diversi (una capability
|
|
17
|
+
* che autorizza un'azione PRIMA che avvenga, contro una ricevuta che
|
|
18
|
+
* attesta un'azione GIÀ avvenuta) restano due file, come `TalosDisco`/
|
|
19
|
+
* `discoNode.ts`/`discoCapacitor.ts` restano tre file per lo stesso
|
|
20
|
+
* concetto su runtime diversi — non una libreria condivisa forzata.
|
|
21
|
+
*
|
|
22
|
+
* ⛔ Perché una chiave PERSISTITA e non una generata a ogni avvio (come fa
|
|
23
|
+
* `control-plane/scripts/dev-stack.mjs` per il suo token JWT effimero):
|
|
24
|
+
* una ricevuta d'audit deve restare verificabile MOLTO dopo che il
|
|
25
|
+
* processo che l'ha firmata è morto — un riavvio del server non deve
|
|
26
|
+
* invalidare ogni firma emessa prima. Il token JWT del dev-stack vive
|
|
27
|
+
* solo per la durata di una sessione locale fra due processi che
|
|
28
|
+
* ripartono insieme; questa chiave no.
|
|
29
|
+
*/
|
|
30
|
+
import {
|
|
31
|
+
createPrivateKey,
|
|
32
|
+
createPublicKey,
|
|
33
|
+
generateKeyPairSync,
|
|
34
|
+
randomUUID,
|
|
35
|
+
timingSafeEqual,
|
|
36
|
+
} from 'node:crypto';
|
|
37
|
+
import { chmod, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
|
|
41
|
+
const PRIVATE_KEY_ENV = 'TALOS_HARNESS_RECEIPT_PRIVATE_KEY_B64';
|
|
42
|
+
const PUBLIC_KEY_ENV = 'TALOS_HARNESS_RECEIPT_PUBLIC_KEY_B64';
|
|
43
|
+
const KEY_ID_ENV = 'TALOS_HARNESS_RECEIPT_KEY_ID';
|
|
44
|
+
const RECEIPT_KEY_ENV_NAMES = [PRIVATE_KEY_ENV, PUBLIC_KEY_ENV, KEY_ID_ENV];
|
|
45
|
+
const KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
|
46
|
+
|
|
47
|
+
export function generateHarnessReceiptKeypair() {
|
|
48
|
+
const { privateKey, publicKey } = generateKeyPairSync('ed25519', {
|
|
49
|
+
privateKeyEncoding: { format: 'pem', type: 'pkcs8' },
|
|
50
|
+
publicKeyEncoding: { format: 'pem', type: 'spki' },
|
|
51
|
+
});
|
|
52
|
+
const keypair = {
|
|
53
|
+
keyId: `talos-harness-receipt-${randomUUID()}`,
|
|
54
|
+
privateKeyBase64: Buffer.from(privateKey, 'utf8').toString('base64'),
|
|
55
|
+
publicKeyBase64: Buffer.from(publicKey, 'utf8').toString('base64'),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
assertValidHarnessReceiptKeypair(keypair);
|
|
59
|
+
|
|
60
|
+
return keypair;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function ensureHarnessReceiptKeypairEnvFile(envFile) {
|
|
64
|
+
const absolutePath = path.resolve(envFile);
|
|
65
|
+
const contents = await readFile(absolutePath, 'utf8').catch((error) => {
|
|
66
|
+
if (error?.code === 'ENOENT') return '';
|
|
67
|
+
throw error;
|
|
68
|
+
});
|
|
69
|
+
const parsed = parseEnv(contents);
|
|
70
|
+
const configured = {
|
|
71
|
+
keyId: parsed.get(KEY_ID_ENV)?.trim() ?? '',
|
|
72
|
+
privateKeyBase64: parsed.get(PRIVATE_KEY_ENV)?.trim() ?? '',
|
|
73
|
+
publicKeyBase64: parsed.get(PUBLIC_KEY_ENV)?.trim() ?? '',
|
|
74
|
+
};
|
|
75
|
+
const presentCount = Object.values(configured).filter((value) => value !== '').length;
|
|
76
|
+
|
|
77
|
+
if (presentCount > 0 && presentCount < 3) {
|
|
78
|
+
throw new Error('Refusing partial TALOS harness receipt keypair configuration.');
|
|
79
|
+
}
|
|
80
|
+
if (presentCount === 3) {
|
|
81
|
+
assertValidHarnessReceiptKeypair(configured);
|
|
82
|
+
await chmod(absolutePath, 0o600).catch((error) => {
|
|
83
|
+
if (process.platform !== 'win32') throw error;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return { created: false, keypair: configured };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const keypair = generateHarnessReceiptKeypair();
|
|
90
|
+
const updated = setEnvValues(contents, new Map([
|
|
91
|
+
[PRIVATE_KEY_ENV, keypair.privateKeyBase64],
|
|
92
|
+
[PUBLIC_KEY_ENV, keypair.publicKeyBase64],
|
|
93
|
+
[KEY_ID_ENV, keypair.keyId],
|
|
94
|
+
]));
|
|
95
|
+
const temporaryPath = `${absolutePath}.tmp-${process.pid}-${randomUUID()}`;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await writeFile(temporaryPath, updated, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
99
|
+
await rename(temporaryPath, absolutePath);
|
|
100
|
+
await chmod(absolutePath, 0o600).catch((error) => {
|
|
101
|
+
if (process.platform !== 'win32') throw error;
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
await rm(temporaryPath, { force: true }).catch(() => {});
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { created: true, keypair };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function assertValidHarnessReceiptKeypair(keypair) {
|
|
112
|
+
if (!KEY_ID_PATTERN.test(keypair.keyId)) throw configurationError();
|
|
113
|
+
|
|
114
|
+
const privatePem = decodeCanonicalBase64(keypair.privateKeyBase64);
|
|
115
|
+
const publicPem = decodeCanonicalBase64(keypair.publicKeyBase64);
|
|
116
|
+
if (!privatePem.startsWith('-----BEGIN PRIVATE KEY-----\n')
|
|
117
|
+
|| !privatePem.trimEnd().endsWith('-----END PRIVATE KEY-----')
|
|
118
|
+
|| !publicPem.startsWith('-----BEGIN PUBLIC KEY-----\n')
|
|
119
|
+
|| !publicPem.trimEnd().endsWith('-----END PUBLIC KEY-----')) {
|
|
120
|
+
throw configurationError();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const privateKey = createPrivateKey(privatePem);
|
|
125
|
+
const publicKey = createPublicKey(publicPem);
|
|
126
|
+
if (privateKey.asymmetricKeyType !== 'ed25519' || publicKey.asymmetricKeyType !== 'ed25519') {
|
|
127
|
+
throw configurationError();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const expectedPublic = createPublicKey(privateKey).export({ format: 'der', type: 'spki' });
|
|
131
|
+
const suppliedPublic = publicKey.export({ format: 'der', type: 'spki' });
|
|
132
|
+
if (expectedPublic.length !== suppliedPublic.length
|
|
133
|
+
|| !timingSafeEqual(expectedPublic, suppliedPublic)) {
|
|
134
|
+
throw configurationError();
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
throw configurationError();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function decodeCanonicalBase64(value) {
|
|
142
|
+
if (typeof value !== 'string'
|
|
143
|
+
|| value === ''
|
|
144
|
+
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)) {
|
|
145
|
+
throw configurationError();
|
|
146
|
+
}
|
|
147
|
+
const decoded = Buffer.from(value, 'base64');
|
|
148
|
+
if (decoded.toString('base64') !== value) throw configurationError();
|
|
149
|
+
|
|
150
|
+
return decoded.toString('utf8');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseEnv(contents) {
|
|
154
|
+
const values = new Map();
|
|
155
|
+
const occurrences = new Map();
|
|
156
|
+
for (const line of contents.split(/\r?\n/u)) {
|
|
157
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(line);
|
|
158
|
+
if (!match) continue;
|
|
159
|
+
const [, name, value] = match;
|
|
160
|
+
occurrences.set(name, (occurrences.get(name) ?? 0) + 1);
|
|
161
|
+
values.set(name, value);
|
|
162
|
+
}
|
|
163
|
+
for (const name of RECEIPT_KEY_ENV_NAMES) {
|
|
164
|
+
if ((occurrences.get(name) ?? 0) > 1) {
|
|
165
|
+
throw new Error(`Refusing duplicate ${name} entries in the environment file.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return values;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function setEnvValues(contents, values) {
|
|
173
|
+
const eol = contents.includes('\r\n') ? '\r\n' : '\n';
|
|
174
|
+
const lines = contents === '' ? [] : contents.split(/\r?\n/u);
|
|
175
|
+
if (lines.at(-1) === '') lines.pop();
|
|
176
|
+
|
|
177
|
+
for (const [name, value] of values) {
|
|
178
|
+
const index = lines.findIndex((line) => line.startsWith(`${name}=`));
|
|
179
|
+
const replacement = `${name}=${value}`;
|
|
180
|
+
if (index >= 0) lines[index] = replacement;
|
|
181
|
+
else lines.push(replacement);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return `${lines.join(eol)}${eol}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function configurationError() {
|
|
188
|
+
return new Error('TALOS harness receipt keypair must be a matching Ed25519 PKCS8/SPKI pair with a valid key id.');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function runCli() {
|
|
192
|
+
const [, , flag, target, ...rest] = process.argv;
|
|
193
|
+
if (flag !== '--env-file' || typeof target !== 'string' || target.trim() === '' || rest.length > 0) {
|
|
194
|
+
throw new Error('Usage: node src/harness-receipt-keypair.mjs --env-file <path>');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const result = await ensureHarnessReceiptKeypairEnvFile(target);
|
|
198
|
+
process.stdout.write(`Harness receipt keypair is ready (${result.created ? 'created' : 'existing'}).\n`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
|
202
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
203
|
+
runCli().catch((error) => {
|
|
204
|
+
process.stderr.write(`${error instanceof Error ? error.message : 'Harness receipt keypair provisioning failed.'}\n`);
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import { mkdir, stat, rm } from 'node:fs/promises';
|
|
5
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const HOST_OK = (host) => host === 'huggingface.co' || host.endsWith('.huggingface.co') || host.endsWith('.hf.co');
|
|
8
|
+
|
|
9
|
+
export class HfDirectTransferError extends Error {
|
|
10
|
+
constructor(message, code = 'HF_TRANSFER_FAILED') { super(message); this.name = 'HfDirectTransferError'; this.code = code; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fail(message, code = 'HF_TRANSFER_INVALID') { throw new HfDirectTransferError(message, code); }
|
|
14
|
+
const LOCAL_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/iu;
|
|
15
|
+
const LOCAL_NAME = /^.{1,160}$/su;
|
|
16
|
+
const MAX_LOCAL_IMPORT_BYTES_DEFAULT = 64 * 1024 ** 3;
|
|
17
|
+
function inside(root, value) { const absolute = resolve(root, value); const rel = relative(resolve(root), absolute); if (rel === '..' || rel.startsWith(`..${'\\'}`) || isAbsolute(rel)) fail('model path escapes root', 'HF_PATH_REJECTED'); return absolute; }
|
|
18
|
+
function validateManifest(value) {
|
|
19
|
+
if (!value || typeof value !== 'object' || typeof value.id !== 'string' || typeof value.repo !== 'string' || typeof value.revision !== 'string' || !/^[a-f0-9]{40,64}$/iu.test(value.revision) || !Array.isArray(value.files) || value.files.length === 0 || !Number.isSafeInteger(value.bytes) || value.bytes <= 0 || typeof value.path !== 'string' || isAbsolute(value.path)) fail('download manifest is invalid');
|
|
20
|
+
const files = value.files.map((file) => {
|
|
21
|
+
if (!file || typeof file.path !== 'string' || !file.path || file.path.startsWith('/') || file.path.split('/').some((part) => !part || part === '.' || part === '..') || !Number.isSafeInteger(file.bytes) || file.bytes <= 0 || !/^[a-f0-9]{64}$/iu.test(file.sha256)) fail('download file is invalid');
|
|
22
|
+
return { path: file.path, bytes: file.bytes, sha256: file.sha256.toLowerCase() };
|
|
23
|
+
});
|
|
24
|
+
if (files.reduce((sum, file) => sum + file.bytes, 0) !== value.bytes) fail('download byte total is invalid');
|
|
25
|
+
return { ...value, files, sha256: typeof value.sha256 === 'string' ? value.sha256.toLowerCase() : files[0].sha256, state: 'incomplete' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function digest(path) { const hash = createHash('sha256'); let bytes = 0; const stream = (await import('node:fs')).createReadStream(path); for await (const chunk of stream) { hash.update(chunk); bytes += chunk.length; } return { bytes, sha256: hash.digest('hex') }; }
|
|
29
|
+
|
|
30
|
+
export function createHfDirectTransfer({ rootDir, modelStore, hubClient, fetchImpl = fetch, now = () => new Date(), maxConcurrent = 2, maxImportBytes = MAX_LOCAL_IMPORT_BYTES_DEFAULT } = {}) {
|
|
31
|
+
if (typeof rootDir !== 'string' || !isAbsolute(rootDir) || !modelStore || typeof modelStore.inspect !== 'function' || typeof modelStore.register !== 'function' || typeof modelStore.setState !== 'function' || !hubClient || typeof hubClient.resolveDownload !== 'function') fail('direct transfer dependencies are invalid', 'HF_TRANSFER_MISCONFIGURED');
|
|
32
|
+
const records = new Map(); const queue = []; let active = 0;
|
|
33
|
+
if (!Number.isSafeInteger(maxImportBytes) || maxImportBytes <= 0) fail('local import limit is invalid', 'HF_TRANSFER_MISCONFIGURED');
|
|
34
|
+
async function verify(request) {
|
|
35
|
+
for (const file of request.files) {
|
|
36
|
+
const target = inside(rootDir, join(request.path, file.path));
|
|
37
|
+
let actual; try { actual = await digest(target); } catch { fail(`cannot read ${file.path}`, 'MODEL_FILE_UNREADABLE'); }
|
|
38
|
+
if (actual.bytes !== file.bytes || actual.sha256 !== file.sha256) fail(`checksum mismatch for ${file.path}`, 'CHECKSUM_MISMATCH');
|
|
39
|
+
}
|
|
40
|
+
return modelStore.setState(request.id, 'ready');
|
|
41
|
+
}
|
|
42
|
+
async function download(record) {
|
|
43
|
+
const { request } = record;
|
|
44
|
+
for (const file of request.files) {
|
|
45
|
+
if (record.abort.signal.aborted) throw new HfDirectTransferError('download cancelled', 'CANCELLED_BY_OWNER');
|
|
46
|
+
const target = inside(rootDir, join(request.path, file.path)); await mkdir(resolve(target, '..'), { recursive: true });
|
|
47
|
+
let offset = 0; try { offset = (await stat(`${target}.partial`)).size; } catch {}
|
|
48
|
+
const signed = await hubClient.resolveDownload(request.repo, request.revision, file.path);
|
|
49
|
+
const parsed = new URL(signed.url); if (parsed.protocol !== 'https:' || !HOST_OK(parsed.hostname)) fail('download host is not official', 'HF_REDIRECT_HOST_REJECTED');
|
|
50
|
+
const headers = offset > 0 && offset < file.bytes ? { Range: `bytes=${offset}-` } : {};
|
|
51
|
+
if (offset >= file.bytes) offset = 0;
|
|
52
|
+
const response = await fetchImpl(signed.url, { headers, signal: record.abort.signal });
|
|
53
|
+
if (!response.ok || !response.body) fail(`Hugging Face download HTTP ${response.status}`, 'HF_DOWNLOAD_FAILED');
|
|
54
|
+
if (offset > 0 && response.status !== 206) { await rm(`${target}.partial`, { force: true }); offset = 0; }
|
|
55
|
+
const output = createWriteStream(`${target}.partial`, { flags: offset > 0 ? 'a' : 'w' });
|
|
56
|
+
let received = offset; const reader = response.body.getReader ? response.body.getReader() : null;
|
|
57
|
+
try {
|
|
58
|
+
if (reader) { while (true) { const { value, done } = await reader.read(); if (done) break; if (value) { if (!output.write(Buffer.from(value))) await new Promise((resolveWrite) => output.once('drain', resolveWrite)); received += value.byteLength; record.bytes = record.baseBytes + received; record.progress = Math.min(99, Math.round((record.bytes / request.bytes) * 100)); } } } else { for await (const chunk of response.body) { if (!output.write(Buffer.from(chunk))) await new Promise((resolveWrite) => output.once('drain', resolveWrite)); received += chunk.length; record.bytes = record.baseBytes + received; record.progress = Math.min(99, Math.round((record.bytes / request.bytes) * 100)); } }
|
|
59
|
+
await new Promise((resolveClose, rejectClose) => { output.once('close', resolveClose); output.once('error', rejectClose); output.end(); });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
output.destroy();
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
await (await import('node:fs/promises')).rename(`${target}.partial`, target); record.baseBytes += file.bytes;
|
|
65
|
+
}
|
|
66
|
+
record.state = 'verifying'; await verify(request); record.state = 'ready'; record.progress = 100; record.bytes = request.bytes; record.finishedAt = now().toISOString();
|
|
67
|
+
}
|
|
68
|
+
function pump() { while (active < maxConcurrent && queue.length) { const record = queue.shift(); active += 1; if (record.state === 'queued') record.state = 'running'; download(record).catch((error) => { if (!['paused', 'cancelled'].includes(record.state)) { record.state = 'failed'; record.reason = error.code || 'HF_DOWNLOAD_FAILED'; } }).finally(() => { active -= 1; pump(); }); } }
|
|
69
|
+
async function start(value) {
|
|
70
|
+
const request = validateManifest(value); const existing = await modelStore.inspect(request.id); if (existing && (existing.repo !== request.repo || existing.revision !== request.revision)) fail('existing transfer has different revision', 'HF_TRANSFER_COLLISION');
|
|
71
|
+
if (existing?.state === 'ready') {
|
|
72
|
+
const ready = { id: request.id, request: existing, state: 'ready', progress: 100, bytes: existing.bytes, baseBytes: existing.bytes, reason: null, startedAt: existing.updatedAt, abort: new AbortController() };
|
|
73
|
+
records.set(request.id, ready);
|
|
74
|
+
return status(request.id);
|
|
75
|
+
}
|
|
76
|
+
if (!existing) await modelStore.register({ ...request, state: 'incomplete', updatedAt: now().toISOString() });
|
|
77
|
+
const previous = records.get(request.id); if (previous && ['queued', 'running', 'verifying'].includes(previous.state)) return status(request.id);
|
|
78
|
+
const record = { id: request.id, request, state: 'queued', progress: 0, bytes: 0, baseBytes: 0, reason: null, startedAt: now().toISOString(), abort: new AbortController() }; records.set(request.id, record); queue.push(record); pump();
|
|
79
|
+
return status(request.id);
|
|
80
|
+
}
|
|
81
|
+
// 06/09 B6.10: la coda a schermo dice il FILE e il repository, non l'id interno (`repo`, `file`, `name`, `finishedAt`)
|
|
82
|
+
function status(id) { const record = records.get(id); return record ? { id: record.id, state: record.state, progress: record.progress, bytes: record.bytes, totalBytes: record.request.bytes, reason: record.reason, startedAt: record.startedAt, repo: record.request.repo ?? null, file: record.request.files?.[0]?.path ?? null, name: record.request.name ?? null, finishedAt: record.finishedAt ?? (record.state === 'ready' ? record.request.updatedAt ?? null : null) } : null; }
|
|
83
|
+
async function pause(id) { const record = records.get(id); if (!record || !['queued', 'running'].includes(record.state)) return false; record.state = 'paused'; record.reason = 'PAUSED_BY_OWNER'; record.abort.abort(); return true; }
|
|
84
|
+
async function resume(id) {
|
|
85
|
+
const record = records.get(id);
|
|
86
|
+
if (!record) {
|
|
87
|
+
const manifest = await modelStore.inspect(id);
|
|
88
|
+
if (!manifest || manifest.state === 'ready') return false;
|
|
89
|
+
return start(manifest);
|
|
90
|
+
}
|
|
91
|
+
if (!['paused', 'failed'].includes(record.state)) return false;
|
|
92
|
+
return start(record.request);
|
|
93
|
+
}
|
|
94
|
+
async function cancel(id) {
|
|
95
|
+
const record = records.get(id); if (!record || ['ready', 'failed', 'cancelled'].includes(record.state)) return false;
|
|
96
|
+
record.state = 'cancelled'; record.reason = 'CANCELLED_BY_OWNER'; record.abort.abort();
|
|
97
|
+
await Promise.all(record.request.files.map((file) => rm(`${inside(rootDir, join(record.request.path, file.path))}.partial`, { force: true })));
|
|
98
|
+
await modelStore.remove(record.id).catch(() => {});
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
async function verifyById(id) { const record = records.get(id); const manifest = record?.request || await modelStore.inspect(id); if (!manifest) fail('model manifest not found', 'MODEL_NOT_FOUND'); return verify(manifest); }
|
|
102
|
+
function validateImportMetadata(value) {
|
|
103
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.id !== 'string' || !LOCAL_ID.test(value.id)) fail('local model id is invalid', 'LOCAL_IMPORT_INVALID');
|
|
104
|
+
if (typeof value.filename !== 'string' || value.filename.trim() === '' || value.filename.length > 240 || basename(value.filename) !== value.filename || value.filename.includes('\\') || value.filename.includes('/') || !/\.gguf$/iu.test(value.filename)) fail('local model filename is invalid', 'LOCAL_IMPORT_INVALID');
|
|
105
|
+
if (value.expectedBytes !== undefined && (!Number.isSafeInteger(value.expectedBytes) || value.expectedBytes <= 0)) fail('local model size is invalid', 'LOCAL_IMPORT_INVALID');
|
|
106
|
+
if (value.expectedBytes !== undefined && value.expectedBytes > maxImportBytes) fail('local model is too large', 'LOCAL_IMPORT_TOO_LARGE');
|
|
107
|
+
if (value.name !== undefined && (typeof value.name !== 'string' || !LOCAL_NAME.test(value.name.trim()))) fail('local model name is invalid', 'LOCAL_IMPORT_INVALID');
|
|
108
|
+
return { id: value.id, filename: value.filename, expectedBytes: value.expectedBytes ?? null, name: value.name?.trim() || null };
|
|
109
|
+
}
|
|
110
|
+
async function importStream(readable, metadata) {
|
|
111
|
+
if (!readable || typeof readable[Symbol.asyncIterator] !== 'function') fail('local import stream is invalid', 'LOCAL_IMPORT_INVALID');
|
|
112
|
+
const request = validateImportMetadata(metadata);
|
|
113
|
+
if (await modelStore.inspect(request.id)) fail(`model ${request.id} already exists`, 'HF_TRANSFER_COLLISION');
|
|
114
|
+
const target = inside(rootDir, join(request.id, request.filename));
|
|
115
|
+
await mkdir(resolve(target, '..'), { recursive: true });
|
|
116
|
+
const temporary = `${target}.partial-upload-${process.pid}-${Date.now()}`;
|
|
117
|
+
const output = createWriteStream(temporary, { flags: 'wx' });
|
|
118
|
+
const hash = createHash('sha256');
|
|
119
|
+
let total = 0;
|
|
120
|
+
let prefix = Buffer.alloc(0);
|
|
121
|
+
let registered = false;
|
|
122
|
+
try {
|
|
123
|
+
for await (const chunk of readable) {
|
|
124
|
+
const value = Buffer.from(chunk);
|
|
125
|
+
if (!value.length) continue;
|
|
126
|
+
total += value.length;
|
|
127
|
+
if (total > maxImportBytes) fail('local model is too large', 'LOCAL_IMPORT_TOO_LARGE');
|
|
128
|
+
if (request.expectedBytes !== null && total > request.expectedBytes) fail('local model size does not match', 'LOCAL_IMPORT_SIZE_MISMATCH');
|
|
129
|
+
if (prefix.length < 4) prefix = Buffer.concat([prefix, value.subarray(0, 4 - prefix.length)]);
|
|
130
|
+
hash.update(value);
|
|
131
|
+
if (!output.write(value)) await once(output, 'drain');
|
|
132
|
+
}
|
|
133
|
+
await new Promise((resolveClose, rejectClose) => output.end((error) => error ? rejectClose(error) : resolveClose()));
|
|
134
|
+
if (total === 0) fail('local model is empty', 'LOCAL_IMPORT_EMPTY');
|
|
135
|
+
if (request.expectedBytes !== null && total !== request.expectedBytes) fail('local model size does not match', 'LOCAL_IMPORT_SIZE_MISMATCH');
|
|
136
|
+
if (prefix.toString('ascii') !== 'GGUF') fail('local file is not a GGUF model', 'LOCAL_IMPORT_NOT_GGUF');
|
|
137
|
+
const sha256 = hash.digest('hex');
|
|
138
|
+
await (await import('node:fs/promises')).rename(temporary, target);
|
|
139
|
+
const manifest = { id: request.id, repo: 'local-upload', revision: sha256, files: [{ path: request.filename, bytes: total, sha256 }], bytes: total, sha256, license: 'unknown', path: request.id, state: 'ready', updatedAt: now().toISOString() };
|
|
140
|
+
try {
|
|
141
|
+
await modelStore.register(manifest);
|
|
142
|
+
registered = true;
|
|
143
|
+
if (request.name && typeof modelStore.rename === 'function') await modelStore.rename(request.id, request.name);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
await rm(target, { force: true }).catch(() => {});
|
|
146
|
+
if (registered) await modelStore.remove?.(request.id).catch?.(() => {});
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
return (await modelStore.inspect(request.id)) || manifest;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
output.destroy();
|
|
152
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function listStatuses() {
|
|
157
|
+
for (const manifest of await modelStore.list()) {
|
|
158
|
+
if (records.has(manifest.id)) continue;
|
|
159
|
+
if (manifest.state === 'ready') {
|
|
160
|
+
records.set(manifest.id, { id: manifest.id, request: manifest, state: 'ready', progress: 100, bytes: manifest.bytes, baseBytes: manifest.bytes, reason: null, startedAt: manifest.updatedAt, abort: new AbortController() });
|
|
161
|
+
} else {
|
|
162
|
+
let bytes = 0;
|
|
163
|
+
for (const file of manifest.files) { try { bytes += (await stat(`${inside(rootDir, join(manifest.path, file.path))}.partial`)).size; } catch {} }
|
|
164
|
+
records.set(manifest.id, { id: manifest.id, request: manifest, state: 'paused', progress: Math.min(99, Math.round((bytes / manifest.bytes) * 100)), bytes, baseBytes: 0, reason: 'RECOVERED_AFTER_RESTART', startedAt: manifest.updatedAt, abort: new AbortController() });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return [...records.keys()].map((id) => status(id)).filter(Boolean);
|
|
168
|
+
}
|
|
169
|
+
return Object.freeze({ start, status, listStatuses, pause, resume, cancel, verify: verifyById, importStream });
|
|
170
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const REVISION = /^[a-f0-9]{40,64}$/iu;
|
|
2
|
+
const REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,95}\/[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/u;
|
|
3
|
+
const OFFICIAL_HOSTS = new Set(['huggingface.co']);
|
|
4
|
+
|
|
5
|
+
export class HfHubError extends Error {
|
|
6
|
+
constructor(message, code = 'HF_HUB_ERROR') { super(message); this.name = 'HfHubError'; this.code = code; }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function invalid(message) { throw new HfHubError(message, 'HF_HUB_INVALID'); }
|
|
10
|
+
function ensureRepo(repo) { if (typeof repo !== 'string' || !REPO.test(repo)) invalid('repository id is invalid'); return repo; }
|
|
11
|
+
function ensureRevision(revision) { if (typeof revision !== 'string' || !REVISION.test(revision)) invalid('revision is invalid'); return revision; }
|
|
12
|
+
function ensurePath(path) { if (typeof path !== 'string' || path.trim() === '' || path.startsWith('/') || path.split('/').some((part) => !part || part === '.' || part === '..')) invalid('file path is invalid'); return path; }
|
|
13
|
+
|
|
14
|
+
function extractModelCardImages(readme, { repo, revision }) {
|
|
15
|
+
const images = [];
|
|
16
|
+
let cleaned = String(readme ?? '');
|
|
17
|
+
const resolve = (raw) => {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = new URL(raw, `https://huggingface.co/${repo}/resolve/${encodeURIComponent(revision)}/`);
|
|
20
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) return null;
|
|
21
|
+
return parsed.toString();
|
|
22
|
+
} catch { return null; }
|
|
23
|
+
};
|
|
24
|
+
cleaned = cleaned.replace(/<img\b[^>]*>/giu, (tag) => {
|
|
25
|
+
const source = /\bsrc\s*=\s*["']([^"']+)["']/iu.exec(tag)?.[1];
|
|
26
|
+
const alt = /\balt\s*=\s*["']([^"']*)["']/iu.exec(tag)?.[1] ?? '';
|
|
27
|
+
const url = source ? resolve(source) : null;
|
|
28
|
+
if (url) images.push({ alt, url });
|
|
29
|
+
return '';
|
|
30
|
+
});
|
|
31
|
+
cleaned = cleaned.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^)]*["'])?\)/gu, (_match, alt, source) => {
|
|
32
|
+
const url = resolve(source);
|
|
33
|
+
if (url) images.push({ alt, url });
|
|
34
|
+
return '';
|
|
35
|
+
});
|
|
36
|
+
return { readme: cleaned.replace(/\n{3,}/gu, '\n\n').trim(), images };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function officialDownloadUrl(value) {
|
|
40
|
+
let parsed;
|
|
41
|
+
try { parsed = new URL(value); } catch { throw new HfHubError('Hugging Face redirect is invalid', 'HF_REDIRECT_INVALID'); }
|
|
42
|
+
if (parsed.protocol !== 'https:' || ![...OFFICIAL_HOSTS].some((host) => parsed.hostname === host || parsed.hostname.endsWith(`.${host}`)) && !parsed.hostname.endsWith('.hf.co')) {
|
|
43
|
+
throw new HfHubError('Hugging Face download host is not official', 'HF_REDIRECT_HOST_REJECTED');
|
|
44
|
+
}
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createHfHubClient({ fetchImpl = fetch, token, baseUrl = 'https://huggingface.co' } = {}) {
|
|
49
|
+
if (typeof fetchImpl !== 'function') throw new HfHubError('fetch implementation is required', 'HF_HUB_MISCONFIGURED');
|
|
50
|
+
const root = new URL(baseUrl);
|
|
51
|
+
if (root.protocol !== 'https:') throw new HfHubError('Hub base URL must use HTTPS', 'HF_HUB_MISCONFIGURED');
|
|
52
|
+
const headers = () => ({ Accept: 'application/json', ...(typeof token === 'string' && token.trim() ? { Authorization: `Bearer ${token.trim()}` } : {}) });
|
|
53
|
+
async function request(path, options = {}) {
|
|
54
|
+
const response = await fetchImpl(new URL(path, root), { ...options, headers: { ...headers(), ...(options.headers || {}) } });
|
|
55
|
+
if (response.ok || (options.redirect === 'manual' && response.status >= 300 && response.status < 400)) return response;
|
|
56
|
+
if (response.status === 401 || response.status === 403) throw new HfHubError('Repository Hugging Face gated o non autorizzato', 'HF_REPOSITORY_GATED');
|
|
57
|
+
if (response.status === 429) throw new HfHubError('Limite richieste Hugging Face raggiunto', 'HF_RATE_LIMITED');
|
|
58
|
+
throw new HfHubError(`Hugging Face HTTP ${response.status}`, 'HF_HUB_UPSTREAM');
|
|
59
|
+
}
|
|
60
|
+
async function searchModels({ query = '', limit = 20, cursor = null, sort = 'downloads', direction = '-1', author = null, filters = [] } = {}) {
|
|
61
|
+
const allowedSort = new Set(['downloads', 'likes', 'created', 'lastModified']);
|
|
62
|
+
const allowedDirection = new Set(['-1', '1']);
|
|
63
|
+
if (typeof query !== 'string' || query.length > 200 || !Number.isInteger(limit) || limit < 1 || limit > 50 || !allowedSort.has(sort) || !allowedDirection.has(String(direction)) || (author !== null && (typeof author !== 'string' || author.length > 100)) || !Array.isArray(filters) || filters.some((filter) => typeof filter !== 'string' || filter.length === 0 || filter.length > 80)) invalid('search parameters are invalid');
|
|
64
|
+
const params = new URLSearchParams({ sort, direction: String(direction), limit: String(limit) });
|
|
65
|
+
params.append('filter', 'gguf');
|
|
66
|
+
for (const filter of filters) params.append('filter', filter);
|
|
67
|
+
if (author?.trim()) params.set('author', author.trim());
|
|
68
|
+
for (const field of ['sha', 'gguf', 'downloads', 'downloadsAllTime', 'likes', 'pipeline_tag', 'tags', 'siblings', 'cardData']) params.append('expand[]', field);
|
|
69
|
+
if (query.trim()) params.set('search', query.trim());
|
|
70
|
+
if (cursor) params.set('cursor', String(cursor));
|
|
71
|
+
const response = await request(`/api/models?${params}`);
|
|
72
|
+
const rows = await response.json();
|
|
73
|
+
const payload = Array.isArray(rows) ? rows : (Array.isArray(rows?.items) ? rows.items : (Array.isArray(rows?.data) ? rows.data : []));
|
|
74
|
+
const items = payload.map((row) => ({ repo: row.id, revision: /^[a-f0-9]{40,64}$/iu.test(row.sha || '') ? row.sha : null, downloads: Number.isFinite(row.downloads) ? row.downloads : null, likes: Number.isFinite(row.likes) ? row.likes : null, gated: row.gated === true, pipelineTag: row.pipeline_tag || null, license: row.cardData?.license || row.license || null, tags: Array.isArray(row.tags) ? row.tags.slice(0, 40) : [] }));
|
|
75
|
+
const nextCursor = rows?.next ?? rows?.next_cursor ?? rows?.nextCursor ?? response.headers.get('x-next-cursor') ?? null;
|
|
76
|
+
return { items, nextCursor: typeof nextCursor === 'string' && nextCursor ? nextCursor : null };
|
|
77
|
+
}
|
|
78
|
+
async function describeModel(repo, revision = 'main') {
|
|
79
|
+
ensureRepo(repo); const rev = revision === 'main' ? revision : ensureRevision(revision);
|
|
80
|
+
const [meta, readme] = await Promise.all([request(`/api/models/${repo}`).then((r) => r.json()), request(`/${repo}/raw/${encodeURIComponent(rev)}/README.md`, { headers: { Accept: 'text/plain' } }).then((r) => r.text()).catch((error) => error.code === 'HF_HUB_UPSTREAM' ? '' : Promise.reject(error))]);
|
|
81
|
+
const revisionResolved = /^[a-f0-9]{40,64}$/iu.test(meta.sha || '') ? meta.sha : rev;
|
|
82
|
+
const card = extractModelCardImages(readme, { repo, revision: revisionResolved });
|
|
83
|
+
return { repo, revision: revisionResolved, gated: meta.gated === true, license: meta.cardData?.license || meta.license || null, readme: card.readme, images: card.images, downloads: meta.downloads ?? null, likes: meta.likes ?? null, pipelineTag: meta.pipeline_tag || null };
|
|
84
|
+
}
|
|
85
|
+
async function listGgufFiles(repo, revision) {
|
|
86
|
+
ensureRepo(repo); ensureRevision(revision);
|
|
87
|
+
const rows = await (await request(`/api/models/${repo}/tree/${encodeURIComponent(revision)}?recursive=true`)).json();
|
|
88
|
+
return (Array.isArray(rows) ? rows : []).filter((row) => row.type === 'file' && typeof row.path === 'string' && row.path.toLowerCase().endsWith('.gguf')).map((row) => ({ path: row.path, sizeBytes: Number(row.size), sha256: row.lfs?.oid || row.oid || null, security: row.security?.status || null }));
|
|
89
|
+
}
|
|
90
|
+
async function pathsInfo(repo, revision, paths) {
|
|
91
|
+
ensureRepo(repo); ensureRevision(revision); if (!Array.isArray(paths) || paths.length === 0 || paths.length > 200 || !paths.every((path) => typeof path === 'string')) invalid('paths are invalid');
|
|
92
|
+
const response = await request(`/api/models/${repo}/paths-info/${encodeURIComponent(revision)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paths, expand: true }) });
|
|
93
|
+
const body = await response.json(); const rows = Array.isArray(body) ? body : body.files;
|
|
94
|
+
if (!Array.isArray(rows)) throw new HfHubError('paths-info response is invalid', 'HF_HUB_RESPONSE_INVALID');
|
|
95
|
+
return rows.map((row) => ({ path: row.path, sizeBytes: Number(row.size), sha256: row.lfs?.oid || row.oid || null, security: row.security?.status || null })).filter((row) => Number.isSafeInteger(row.sizeBytes) && row.sizeBytes > 0);
|
|
96
|
+
}
|
|
97
|
+
async function resolveDownload(repo, revision, path) {
|
|
98
|
+
ensureRepo(repo); ensureRevision(revision); ensurePath(path);
|
|
99
|
+
const response = await request(`/${repo}/resolve/${encodeURIComponent(revision)}/${path.split('/').map(encodeURIComponent).join('/')}`, { redirect: 'manual' });
|
|
100
|
+
const location = response.headers.get('location');
|
|
101
|
+
if (!location) throw new HfHubError('Hugging Face did not return a signed download URL', 'HF_RESOLVE_INVALID');
|
|
102
|
+
const url = officialDownloadUrl(location);
|
|
103
|
+
return { url: url.toString(), expiresAt: url.searchParams.get('Expires') ? Number(url.searchParams.get('Expires')) * 1000 : null };
|
|
104
|
+
}
|
|
105
|
+
return Object.freeze({ searchModels, describeModel, listGgufFiles, pathsInfo, resolveDownload });
|
|
106
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
|
|
4
|
+
const ALLOWED_SUFFIXES = Object.freeze(['huggingface.co', 'hf.co']);
|
|
5
|
+
const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif']);
|
|
6
|
+
|
|
7
|
+
export class HfImageProxyError extends Error {
|
|
8
|
+
constructor(message, code) { super(message); this.name = 'HfImageProxyError'; this.code = code; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fail(message, code) { throw new HfImageProxyError(message, code); }
|
|
12
|
+
|
|
13
|
+
function isAllowedHost(hostname) {
|
|
14
|
+
const host = hostname.toLowerCase().replace(/\.$/u, '');
|
|
15
|
+
return ALLOWED_SUFFIXES.some((suffix) => host === suffix || host.endsWith(`.${suffix}`));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function privateAddress(address) {
|
|
19
|
+
const normalized = String(address).toLowerCase().replace(/^\[|\]$/gu, '');
|
|
20
|
+
if (isIP(normalized) === 4) {
|
|
21
|
+
const parts = normalized.split('.').map(Number);
|
|
22
|
+
const [a, b] = parts;
|
|
23
|
+
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127)
|
|
24
|
+
|| (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|
|
25
|
+
|| (a === 192 && (b === 168 || b === 0)) || (a === 198 && b >= 18 && b <= 19)
|
|
26
|
+
|| a >= 224;
|
|
27
|
+
}
|
|
28
|
+
if (isIP(normalized) === 6) {
|
|
29
|
+
const first = Number.parseInt(normalized.split(':')[0] || '0', 16);
|
|
30
|
+
return normalized === '::' || normalized === '::1' || normalized.startsWith('fe80:')
|
|
31
|
+
|| (first >= 0xfc00 && first <= 0xfdff) || normalized.startsWith('::ffff:') && privateAddress(normalized.slice(7));
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function validateRemoteUrl(value, { redirect = false, lookupFn }) {
|
|
37
|
+
let url;
|
|
38
|
+
try { url = value instanceof URL ? new URL(value) : new URL(String(value)); } catch { fail('Hugging Face image URL is invalid', redirect ? 'HF_IMAGE_REDIRECT_REJECTED' : 'HF_IMAGE_URL_INVALID'); }
|
|
39
|
+
if (url.protocol !== 'https:' || url.username || url.password || !isAllowedHost(url.hostname)) {
|
|
40
|
+
fail('Hugging Face image host is not allowed', redirect ? 'HF_IMAGE_REDIRECT_REJECTED' : 'HF_IMAGE_HOST_REJECTED');
|
|
41
|
+
}
|
|
42
|
+
let records;
|
|
43
|
+
try { records = await lookupFn(url.hostname, { all: true, verbatim: true }); } catch { fail('Hugging Face image host could not be resolved', 'HF_IMAGE_DNS_FAILED'); }
|
|
44
|
+
const addresses = Array.isArray(records) ? records : [records];
|
|
45
|
+
if (!addresses.length || addresses.some((record) => privateAddress(record?.address ?? record))) fail('Hugging Face image host resolves to a private address', redirect ? 'HF_IMAGE_REDIRECT_REJECTED' : 'HF_IMAGE_PRIVATE_ADDRESS');
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function combinedSignal(timeoutMs, externalSignal) {
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
const timer = setTimeout(() => controller.abort('timeout'), timeoutMs);
|
|
52
|
+
timer.unref?.();
|
|
53
|
+
const onAbort = () => controller.abort(externalSignal.reason);
|
|
54
|
+
if (externalSignal?.aborted) onAbort();
|
|
55
|
+
else externalSignal?.addEventListener?.('abort', onAbort, { once: true });
|
|
56
|
+
return { signal: typeof AbortSignal.any === 'function' && externalSignal ? AbortSignal.any([controller.signal, externalSignal]) : controller.signal, close: () => { clearTimeout(timer); externalSignal?.removeEventListener?.('abort', onAbort); } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readLimited(response, maxBytes) {
|
|
60
|
+
const advertised = Number(response.headers?.get?.('content-length'));
|
|
61
|
+
if (Number.isFinite(advertised) && advertised > maxBytes) fail('Hugging Face image is too large', 'HF_IMAGE_TOO_LARGE');
|
|
62
|
+
if (response.body?.getReader) {
|
|
63
|
+
const reader = response.body.getReader();
|
|
64
|
+
const chunks = []; let total = 0;
|
|
65
|
+
while (true) {
|
|
66
|
+
const { value, done } = await reader.read();
|
|
67
|
+
if (done) break;
|
|
68
|
+
total += value?.byteLength ?? 0;
|
|
69
|
+
if (total > maxBytes) { await reader.cancel?.(); fail('Hugging Face image is too large', 'HF_IMAGE_TOO_LARGE'); }
|
|
70
|
+
chunks.push(Buffer.from(value));
|
|
71
|
+
}
|
|
72
|
+
return Buffer.concat(chunks, total);
|
|
73
|
+
}
|
|
74
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
75
|
+
if (bytes.byteLength > maxBytes) fail('Hugging Face image is too large', 'HF_IMAGE_TOO_LARGE');
|
|
76
|
+
return bytes;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function fetchAllowedHfImage(value, { fetchImpl = fetch, lookupFn = lookup, maxBytes = 4 * 1024 * 1024, timeoutMs = 15_000, signal = null } = {}) {
|
|
80
|
+
if (typeof fetchImpl !== 'function' || !Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1) fail('Hugging Face image proxy configuration is invalid', 'HF_IMAGE_CONFIG_INVALID');
|
|
81
|
+
let url = await validateRemoteUrl(value, { lookupFn, redirect: false });
|
|
82
|
+
const lifecycle = combinedSignal(timeoutMs, signal);
|
|
83
|
+
try {
|
|
84
|
+
for (let redirects = 0; redirects <= 3; redirects += 1) {
|
|
85
|
+
let response;
|
|
86
|
+
try { response = await fetchImpl(url, { redirect: 'manual', signal: lifecycle.signal, headers: { Accept: 'image/*' } }); }
|
|
87
|
+
catch (error) { if (lifecycle.signal.aborted) fail('Hugging Face image request timed out or was cancelled', 'HF_IMAGE_ABORTED'); fail('Hugging Face image could not be downloaded', 'HF_IMAGE_UNREACHABLE'); }
|
|
88
|
+
if (response.status >= 300 && response.status < 400) {
|
|
89
|
+
if (redirects === 3) fail('Hugging Face image redirected too many times', 'HF_IMAGE_REDIRECT_REJECTED');
|
|
90
|
+
const location = response.headers?.get?.('location');
|
|
91
|
+
if (!location) fail('Hugging Face image redirect is missing its destination', 'HF_IMAGE_REDIRECT_REJECTED');
|
|
92
|
+
let next;
|
|
93
|
+
try { next = new URL(location, url); } catch { fail('Hugging Face image redirect is invalid', 'HF_IMAGE_REDIRECT_REJECTED'); }
|
|
94
|
+
url = await validateRemoteUrl(next, { lookupFn, redirect: true });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!response.ok) fail('Hugging Face image service returned an error', 'HF_IMAGE_UPSTREAM');
|
|
98
|
+
const mimeType = String(response.headers?.get?.('content-type') || '').split(';', 1)[0].trim().toLowerCase();
|
|
99
|
+
if (!ALLOWED_MIME.has(mimeType)) fail('Hugging Face response is not a safe image format', 'HF_IMAGE_MIME_REJECTED');
|
|
100
|
+
const bytes = await readLimited(response, maxBytes);
|
|
101
|
+
return Object.freeze({ bytes, mimeType, sourceUrl: url.toString() });
|
|
102
|
+
}
|
|
103
|
+
} finally { lifecycle.close(); }
|
|
104
|
+
fail('Hugging Face image redirect is invalid', 'HF_IMAGE_REDIRECT_REJECTED');
|
|
105
|
+
}
|