@hasna/recordings 0.3.13 → 0.5.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/README.md +279 -43
- package/bun.lock +37 -38
- package/contracts/v1/fixtures.json +1206 -0
- package/dist/cli/index.js +1909 -619
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/contracts/hosted-v1.d.ts +105 -0
- package/dist/contracts/hosted-v1.d.ts.map +1 -0
- package/dist/contracts/hosted-v1.js +41 -0
- package/dist/contracts/stream-v1.d.ts +117 -0
- package/dist/contracts/stream-v1.d.ts.map +1 -0
- package/dist/contracts/stream-v1.js +35 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +6 -0
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/hosted/index.d.ts +58 -0
- package/dist/hosted/index.d.ts.map +1 -0
- package/dist/hosted/index.js +266 -0
- package/dist/hosted/transport.d.ts +36 -0
- package/dist/hosted/transport.d.ts.map +1 -0
- package/dist/hosted-v1-aavn7ktb.js +4114 -0
- package/dist/hosted-v1-gdr9extc.js +84 -0
- package/dist/http/client.d.ts +183 -22
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2879 -2288
- package/dist/lib/audio-artifact-storage.d.ts +98 -0
- package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
- package/dist/lib/capture-probe.d.ts +3 -3
- package/dist/lib/capture-probe.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/local-opt-in.d.ts +132 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/persistence-probe.d.ts +56 -17
- package/dist/lib/persistence-probe.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1713 -459
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +904 -1
- package/dist/sdk/resolve.d.ts +53 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +6 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud-config.d.ts +12 -0
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +20 -2
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +590 -287
- package/dist/server/migrate-command.d.ts +1 -1
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/openapi.d.ts +21 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1358 -874
- package/dist/store.d.ts +12 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -1
- package/docs/hosted-sdk.md +71 -0
- package/docs/wire-contracts.md +24 -0
- package/package.json +33 -11
- package/packaging/macos/build_release_pkg.sh +3 -3
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -2
- package/scripts/build_companion_cli.sh +7 -1
- package/scripts/ci-linux-suite.ts +23 -13
- package/scripts/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +60 -49
- package/scripts/migrate.ts +4 -20
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +36 -4
- package/scripts/native-core-receipt.py +171 -0
- package/scripts/native_fs_guard.ts +2 -0
- package/scripts/release-suite-gate.ts +227 -150
- package/scripts/resolve_tailscale_cli.sh +24 -3
- package/scripts/smoke_macos_app.sh +21 -21
- package/src/native/Recordings/App/ContentView.swift +53 -68
- package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
- package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
- package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
- package/src/native/Recordings/App/RecordingsApp.swift +185 -9
- package/src/native/Recordings/App/RecordingsListView.swift +60 -112
- package/src/native/Recordings/App/RecordingsStore.swift +124 -109
- package/src/native/Recordings/App/Theme.swift +73 -61
- package/src/native/Recordings/Package.swift +1 -0
- package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
- package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
- package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
- package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
- package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
- package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
- package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +8 -7
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
- package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
- package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
- package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
- package/dist/__tests__/preload.d.ts +0 -2
- package/dist/__tests__/preload.d.ts.map +0 -1
- package/src/native/Recordings/App/SidebarView.swift +0 -212
package/dist/cli/index.js
CHANGED
|
@@ -900,10 +900,10 @@ function collectValues(value, previous) {
|
|
|
900
900
|
|
|
901
901
|
// src/cli/index.ts
|
|
902
902
|
import chalk from "chalk";
|
|
903
|
-
import { spawnSync as
|
|
903
|
+
import { spawnSync as spawnSync9 } from "child_process";
|
|
904
904
|
import {
|
|
905
905
|
existsSync as existsSync8,
|
|
906
|
-
readFileSync as
|
|
906
|
+
readFileSync as readFileSync6,
|
|
907
907
|
readdirSync as readdirSync2
|
|
908
908
|
} from "fs";
|
|
909
909
|
import { dirname as dirname5, join as pathJoin } from "path";
|
|
@@ -1117,6 +1117,9 @@ var DEFAULT_CONFIG = {
|
|
|
1117
1117
|
db_path: "",
|
|
1118
1118
|
audio_dir: "",
|
|
1119
1119
|
max_recording_seconds: 1800,
|
|
1120
|
+
s3_bucket: "",
|
|
1121
|
+
s3_prefix: "",
|
|
1122
|
+
s3_region: "",
|
|
1120
1123
|
config_warnings: []
|
|
1121
1124
|
};
|
|
1122
1125
|
function loadConfig(configPath) {
|
|
@@ -1143,6 +1146,9 @@ function loadConfig(configPath) {
|
|
|
1143
1146
|
if (process.env.RECORDINGS_API_KEY) {
|
|
1144
1147
|
config.openai_api_key = process.env.RECORDINGS_API_KEY;
|
|
1145
1148
|
}
|
|
1149
|
+
if (process.env.RECORDINGS_OPENAI_API_KEY) {
|
|
1150
|
+
config.openai_api_key = process.env.RECORDINGS_OPENAI_API_KEY;
|
|
1151
|
+
}
|
|
1146
1152
|
if (process.env.RECORDINGS_ENHANCEMENT_KEY) {
|
|
1147
1153
|
config.enhancement_api_key = process.env.RECORDINGS_ENHANCEMENT_KEY;
|
|
1148
1154
|
}
|
|
@@ -1186,14 +1192,26 @@ function loadConfig(configPath) {
|
|
|
1186
1192
|
if (process.env.RECORDINGS_AUDIO_DIR) {
|
|
1187
1193
|
config.audio_dir = process.env.RECORDINGS_AUDIO_DIR;
|
|
1188
1194
|
}
|
|
1195
|
+
if (process.env.HASNA_RECORDINGS_S3_BUCKET) {
|
|
1196
|
+
config.s3_bucket = process.env.HASNA_RECORDINGS_S3_BUCKET;
|
|
1197
|
+
} else if (process.env.RECORDINGS_S3_BUCKET) {
|
|
1198
|
+
config.s3_bucket = process.env.RECORDINGS_S3_BUCKET;
|
|
1199
|
+
}
|
|
1200
|
+
if (process.env.HASNA_RECORDINGS_S3_PREFIX) {
|
|
1201
|
+
config.s3_prefix = process.env.HASNA_RECORDINGS_S3_PREFIX;
|
|
1202
|
+
} else if (process.env.RECORDINGS_S3_PREFIX) {
|
|
1203
|
+
config.s3_prefix = process.env.RECORDINGS_S3_PREFIX;
|
|
1204
|
+
}
|
|
1205
|
+
if (process.env.RECORDINGS_S3_REGION) {
|
|
1206
|
+
config.s3_region = process.env.RECORDINGS_S3_REGION;
|
|
1207
|
+
} else if (process.env.AWS_REGION) {
|
|
1208
|
+
config.s3_region = process.env.AWS_REGION;
|
|
1209
|
+
}
|
|
1189
1210
|
if (process.env.RECORDINGS_MAX_SECONDS) {
|
|
1190
1211
|
config.max_recording_seconds = parseInt(process.env.RECORDINGS_MAX_SECONDS, 10);
|
|
1191
1212
|
}
|
|
1192
|
-
if (!config.openai_api_key) {
|
|
1193
|
-
config.openai_api_key = loadSecretKey("OPENAI_API_KEY");
|
|
1194
|
-
}
|
|
1195
1213
|
if (!config.enhancement_api_key) {
|
|
1196
|
-
config.enhancement_api_key = config.openai_api_key
|
|
1214
|
+
config.enhancement_api_key = config.openai_api_key;
|
|
1197
1215
|
}
|
|
1198
1216
|
if (!explicitTranscriberModel) {
|
|
1199
1217
|
config.transcriber_model = config.enhancement_model;
|
|
@@ -1373,51 +1391,6 @@ function mergeDirectoryContents(sourceDir, targetDir) {
|
|
|
1373
1391
|
}
|
|
1374
1392
|
}
|
|
1375
1393
|
}
|
|
1376
|
-
function loadSecretKey(keyName) {
|
|
1377
|
-
const secretsPath = join3(getHomeDir(), ".secrets");
|
|
1378
|
-
if (!existsSync2(secretsPath))
|
|
1379
|
-
return "";
|
|
1380
|
-
for (const candidate of listSecretFiles(secretsPath)) {
|
|
1381
|
-
try {
|
|
1382
|
-
const content = readFileSync(candidate, "utf-8");
|
|
1383
|
-
const match = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`));
|
|
1384
|
-
const doubleQuotedValue = match?.[1];
|
|
1385
|
-
if (doubleQuotedValue !== undefined)
|
|
1386
|
-
return doubleQuotedValue;
|
|
1387
|
-
const match2 = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`));
|
|
1388
|
-
const singleQuotedValue = match2?.[1];
|
|
1389
|
-
if (singleQuotedValue !== undefined)
|
|
1390
|
-
return singleQuotedValue;
|
|
1391
|
-
const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
|
|
1392
|
-
const unquotedValue = match3?.[1];
|
|
1393
|
-
if (unquotedValue !== undefined)
|
|
1394
|
-
return unquotedValue.trim().replace(/^["']|["']$/g, "");
|
|
1395
|
-
} catch {}
|
|
1396
|
-
}
|
|
1397
|
-
return "";
|
|
1398
|
-
}
|
|
1399
|
-
function listSecretFiles(path) {
|
|
1400
|
-
try {
|
|
1401
|
-
const stats = statSync(path);
|
|
1402
|
-
if (stats.isFile())
|
|
1403
|
-
return [path];
|
|
1404
|
-
if (!stats.isDirectory())
|
|
1405
|
-
return [];
|
|
1406
|
-
return readdirSync(path).sort().flatMap((entry) => {
|
|
1407
|
-
const child = join3(path, entry);
|
|
1408
|
-
try {
|
|
1409
|
-
const childStats = statSync(child);
|
|
1410
|
-
if (childStats.isDirectory())
|
|
1411
|
-
return listSecretFiles(child);
|
|
1412
|
-
if (childStats.isFile() && child.endsWith(".env"))
|
|
1413
|
-
return [child];
|
|
1414
|
-
} catch {}
|
|
1415
|
-
return [];
|
|
1416
|
-
});
|
|
1417
|
-
} catch {
|
|
1418
|
-
return [];
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
1394
|
function getHomeDir() {
|
|
1422
1395
|
return process.env["HOME"] || process.env["USERPROFILE"] || homedir3();
|
|
1423
1396
|
}
|
|
@@ -1547,6 +1520,11 @@ var MIGRATIONS = [
|
|
|
1547
1520
|
`,
|
|
1548
1521
|
`
|
|
1549
1522
|
ALTER TABLE agents ADD COLUMN active_project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;
|
|
1523
|
+
`,
|
|
1524
|
+
`
|
|
1525
|
+
ALTER TABLE recordings ADD COLUMN audio_object_key TEXT;
|
|
1526
|
+
ALTER TABLE recordings ADD COLUMN audio_sha256 TEXT;
|
|
1527
|
+
ALTER TABLE recordings ADD COLUMN audio_bytes INTEGER;
|
|
1550
1528
|
`
|
|
1551
1529
|
];
|
|
1552
1530
|
var CURRENT_MIGRATION_LEVEL = MIGRATIONS.length - 1;
|
|
@@ -1602,6 +1580,9 @@ function repairSchemaDrift(db) {
|
|
|
1602
1580
|
ensureColumn(db, "recordings", "task_list_id", "TEXT");
|
|
1603
1581
|
ensureColumn(db, "recordings", "machine_id", "TEXT");
|
|
1604
1582
|
ensureColumn(db, "recordings", "metadata", "TEXT DEFAULT '{}'");
|
|
1583
|
+
ensureColumn(db, "recordings", "audio_object_key", "TEXT");
|
|
1584
|
+
ensureColumn(db, "recordings", "audio_sha256", "TEXT");
|
|
1585
|
+
ensureColumn(db, "recordings", "audio_bytes", "INTEGER");
|
|
1605
1586
|
ensureColumn(db, "agents", "active_project_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
|
|
1606
1587
|
}
|
|
1607
1588
|
function ensureColumn(db, table, column, definition) {
|
|
@@ -1677,6 +1658,9 @@ function parseRow(row) {
|
|
|
1677
1658
|
return {
|
|
1678
1659
|
id: row["id"],
|
|
1679
1660
|
audio_path: row["audio_path"] || null,
|
|
1661
|
+
audio_object_key: row["audio_object_key"] || null,
|
|
1662
|
+
audio_sha256: row["audio_sha256"] || null,
|
|
1663
|
+
audio_bytes: Number(row["audio_bytes"] ?? 0) || null,
|
|
1680
1664
|
raw_text: row["raw_text"],
|
|
1681
1665
|
processed_text: row["processed_text"] || null,
|
|
1682
1666
|
processing_mode: row["processing_mode"] || "raw",
|
|
@@ -1708,9 +1692,9 @@ function createRecording(input, db, idempotencyKey) {
|
|
|
1708
1692
|
}
|
|
1709
1693
|
const tagsJson = JSON.stringify(input.tags || []);
|
|
1710
1694
|
const metadataJson = JSON.stringify(input.metadata || {});
|
|
1711
|
-
const insertResult = d.query(`INSERT INTO recordings (id, audio_path, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, goal, role, task_list_id, machine_id, metadata)
|
|
1712
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1713
|
-
ON CONFLICT(id) DO NOTHING`).run(id, input.audio_path || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, tagsJson, input.agent_id || null, input.project_id || null, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, metadataJson);
|
|
1695
|
+
const insertResult = d.query(`INSERT INTO recordings (id, audio_path, audio_object_key, audio_sha256, audio_bytes, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, goal, role, task_list_id, machine_id, metadata)
|
|
1696
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1697
|
+
ON CONFLICT(id) DO NOTHING`).run(id, input.audio_path || null, input.audio_object_key || null, input.audio_sha256 || null, input.audio_bytes || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, tagsJson, input.agent_id || null, input.project_id || null, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, metadataJson);
|
|
1714
1698
|
if (insertResult.changes === 0) {
|
|
1715
1699
|
const existing = getRecording(id, d);
|
|
1716
1700
|
if (existing?.id === id)
|
|
@@ -1727,6 +1711,13 @@ function createRecording(input, db, idempotencyKey) {
|
|
|
1727
1711
|
});
|
|
1728
1712
|
return create();
|
|
1729
1713
|
}
|
|
1714
|
+
function updateRecordingAudio(id, audioObjectKey, audioSha256, audioBytes, db) {
|
|
1715
|
+
const d = db || getDatabase();
|
|
1716
|
+
const result = d.query(`UPDATE recordings SET audio_object_key = ?, audio_sha256 = ?, audio_bytes = ? WHERE id = ?`).run(audioObjectKey, audioSha256, audioBytes, id);
|
|
1717
|
+
if (result.changes === 0)
|
|
1718
|
+
return null;
|
|
1719
|
+
return getRecording(id, d);
|
|
1720
|
+
}
|
|
1730
1721
|
function getRecording(id, db) {
|
|
1731
1722
|
const d = db || getDatabase();
|
|
1732
1723
|
let row = d.query("SELECT * FROM recordings WHERE id = ?").get(id);
|
|
@@ -1928,7 +1919,7 @@ function setAgentFocus(idOrName, projectId, db) {
|
|
|
1928
1919
|
// package.json
|
|
1929
1920
|
var package_default = {
|
|
1930
1921
|
name: "@hasna/recordings",
|
|
1931
|
-
version: "0.
|
|
1922
|
+
version: "0.5.0",
|
|
1932
1923
|
type: "module",
|
|
1933
1924
|
description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
|
|
1934
1925
|
repository: {
|
|
@@ -1954,6 +1945,19 @@ var package_default = {
|
|
|
1954
1945
|
"./sdk": {
|
|
1955
1946
|
import: "./dist/sdk/index.js",
|
|
1956
1947
|
types: "./dist/sdk/index.d.ts"
|
|
1948
|
+
},
|
|
1949
|
+
"./contracts/hosted-v1": {
|
|
1950
|
+
import: "./dist/contracts/hosted-v1.js",
|
|
1951
|
+
types: "./dist/contracts/hosted-v1.d.ts"
|
|
1952
|
+
},
|
|
1953
|
+
"./contracts/stream-v1": {
|
|
1954
|
+
import: "./dist/contracts/stream-v1.js",
|
|
1955
|
+
types: "./dist/contracts/stream-v1.d.ts"
|
|
1956
|
+
},
|
|
1957
|
+
"./contracts/fixtures/v1": "./contracts/v1/fixtures.json",
|
|
1958
|
+
"./hosted": {
|
|
1959
|
+
import: "./dist/hosted/index.js",
|
|
1960
|
+
types: "./dist/hosted/index.d.ts"
|
|
1957
1961
|
}
|
|
1958
1962
|
},
|
|
1959
1963
|
engines: {
|
|
@@ -1962,20 +1966,20 @@ var package_default = {
|
|
|
1962
1966
|
scripts: {
|
|
1963
1967
|
clean: "rm -rf dist",
|
|
1964
1968
|
build: "bun run clean && bun run build:cli && bun run build:mcp && bun run build:serve && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
|
|
1965
|
-
"build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai",
|
|
1966
|
-
"build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai",
|
|
1967
|
-
"build:serve": "bun build src/server/index.ts --target=bun --outfile=dist/server/index.js --external=@modelcontextprotocol/sdk --external=@hasna/contracts --external=openai --external=pg",
|
|
1968
|
-
"build:lib": "bun build src/index.ts src/storage.ts src/sdk/index.ts --target=bun --outdir=dist --external=openai",
|
|
1969
|
+
"build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai --external=@aws-sdk/client-s3",
|
|
1970
|
+
"build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai --external=@aws-sdk/client-s3",
|
|
1971
|
+
"build:serve": "bun build src/server/index.ts --target=bun --outfile=dist/server/index.js --external=@modelcontextprotocol/sdk --external=@hasna/contracts --external=openai --external=pg --external=@aws-sdk/client-s3",
|
|
1972
|
+
"build:lib": "bun build src/index.ts src/storage.ts src/sdk/index.ts --target=bun --outdir=dist --external=openai --external=@aws-sdk/client-s3 && bun run build:contracts",
|
|
1969
1973
|
"build:native-fs-guard": "/bin/bash scripts/build_native_fs_guard.sh",
|
|
1970
1974
|
"generate:sdk": "bun run scripts/generate-sdk.ts",
|
|
1971
1975
|
migrate: "bun run scripts/migrate.ts",
|
|
1972
1976
|
typecheck: "tsc --noEmit",
|
|
1973
1977
|
"typecheck:tcc-contract": "tsc --noEmit -p tsconfig.tcc-contract.json",
|
|
1974
|
-
test: "bun
|
|
1978
|
+
test: "bun scripts/release-suite-gate.ts --all",
|
|
1975
1979
|
"verify:ci-suite": "bun scripts/ci-linux-suite.ts --check",
|
|
1976
1980
|
"verify:ci-suite:run": "bun scripts/ci-linux-suite.ts --verify-run",
|
|
1977
1981
|
"verify:ci-native": "bun scripts/ci-native-build.ts",
|
|
1978
|
-
"test:gated":
|
|
1982
|
+
"test:gated": "bun scripts/release-suite-gate.ts",
|
|
1979
1983
|
"test:vacuity-battery": 'bun scripts/vacuity-manifest-gen.ts > "${TMPDIR:-/tmp}/vacuity-corrupt-sites.tsv" && bun scripts/vacuity-mutation-battery.ts "${TMPDIR:-/tmp}/vacuity-corrupt-sites.tsv"',
|
|
1980
1984
|
"test:vacuity-battery:source": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/source-side.tsv",
|
|
1981
1985
|
"test:vacuity-battery:chain": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/install-chain.tsv",
|
|
@@ -1993,11 +1997,16 @@ var package_default = {
|
|
|
1993
1997
|
"prepack:platform-gate": `bash -c 'if [ "$(uname -s)" = Darwin ]; then bun run build:native-fs-guard; else echo "WARN: native fs-guard build skipped on $(uname -s) \u2014 dry-run/CI pack; publish recordings from macOS"; fi'`,
|
|
1994
1998
|
prepublishOnly: "bun run typecheck && bun run release-suite-gate",
|
|
1995
1999
|
"release-suite-gate": "bun run scripts/release-suite-gate.ts",
|
|
1996
|
-
"typecheck:shortcut-contract": "tsc --noEmit -p tsconfig.test.json"
|
|
2000
|
+
"typecheck:shortcut-contract": "tsc --noEmit -p tsconfig.test.json",
|
|
2001
|
+
"build:contracts": "bun build src/contracts/hosted-v1.ts src/contracts/stream-v1.ts src/hosted/index.ts --target=bun --root src --outdir=dist --splitting --external=@hasna/contracts"
|
|
1997
2002
|
},
|
|
1998
2003
|
files: [
|
|
1999
2004
|
"dist/",
|
|
2005
|
+
"!dist/__tests__/",
|
|
2000
2006
|
"scripts/",
|
|
2007
|
+
"!scripts/test-recorder-fixtures.ts",
|
|
2008
|
+
"!scripts/**/__pycache__/",
|
|
2009
|
+
"!scripts/**/*.pyc",
|
|
2001
2010
|
"Dockerfile.package",
|
|
2002
2011
|
"bun.lock",
|
|
2003
2012
|
"src/native/Recordings/App/",
|
|
@@ -2009,10 +2018,14 @@ var package_default = {
|
|
|
2009
2018
|
"src/native/Recordings/build.sh",
|
|
2010
2019
|
"packaging/macos/",
|
|
2011
2020
|
"README.md",
|
|
2012
|
-
"LICENSE"
|
|
2021
|
+
"LICENSE",
|
|
2022
|
+
"contracts/v1/fixtures.json",
|
|
2023
|
+
"docs/wire-contracts.md",
|
|
2024
|
+
"docs/hosted-sdk.md"
|
|
2013
2025
|
],
|
|
2014
2026
|
dependencies: {
|
|
2015
|
-
"@
|
|
2027
|
+
"@aws-sdk/client-s3": "^3.1007.0",
|
|
2028
|
+
"@hasna/contracts": "1.0.2",
|
|
2016
2029
|
"@hasna/events": "0.1.11",
|
|
2017
2030
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
2018
2031
|
chalk: "^5.4.1",
|
|
@@ -2022,7 +2035,7 @@ var package_default = {
|
|
|
2022
2035
|
zod: "^3.24.2"
|
|
2023
2036
|
},
|
|
2024
2037
|
devDependencies: {
|
|
2025
|
-
"@types/bun": "
|
|
2038
|
+
"@types/bun": "1.3.14",
|
|
2026
2039
|
"@types/pg": "^8.11.11",
|
|
2027
2040
|
"node-api-headers": "1.9.0",
|
|
2028
2041
|
typescript: "^5.8.2"
|
|
@@ -2046,8 +2059,12 @@ function saveFeedback(input) {
|
|
|
2046
2059
|
|
|
2047
2060
|
// ../contracts/dist/client/transport.js
|
|
2048
2061
|
import { isIP } from "net";
|
|
2049
|
-
import {
|
|
2050
|
-
import {
|
|
2062
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2063
|
+
import { closeSync, fstatSync, openSync, readFileSync as readFileSync2 } from "fs";
|
|
2064
|
+
import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
|
|
2065
|
+
import { createRequire } from "module";
|
|
2066
|
+
import { hostname as osHostname } from "os";
|
|
2067
|
+
import { isAbsolute as isAbsolute2, join as join4 } from "path";
|
|
2051
2068
|
function envToken(name) {
|
|
2052
2069
|
return name.toUpperCase().replace(/-/g, "_");
|
|
2053
2070
|
}
|
|
@@ -2062,6 +2079,9 @@ function credentialOverrideEnvKey(name) {
|
|
|
2062
2079
|
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
2063
2080
|
}
|
|
2064
2081
|
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
2082
|
+
function credentialPointerEnvKey(name) {
|
|
2083
|
+
return `HASNA_${envToken(name)}_API_KEY_REF`;
|
|
2084
|
+
}
|
|
2065
2085
|
|
|
2066
2086
|
class CredentialResolutionError extends Error {
|
|
2067
2087
|
appName;
|
|
@@ -2073,34 +2093,70 @@ class CredentialResolutionError extends Error {
|
|
|
2073
2093
|
this.attempted = attempted;
|
|
2074
2094
|
}
|
|
2075
2095
|
}
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2096
|
+
|
|
2097
|
+
class CredentialFileUnsafeError extends Error {
|
|
2098
|
+
path;
|
|
2099
|
+
constructor(path, reason) {
|
|
2100
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
2101
|
+
this.name = "CredentialFileUnsafeError";
|
|
2102
|
+
this.path = path;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
var HASNA_HOME_ENV_KEY = "HASNA_HOME";
|
|
2106
|
+
var HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME";
|
|
2107
|
+
var KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION";
|
|
2108
|
+
var HASNA_HOME_DIR = ".hasna";
|
|
2109
|
+
var CONFIG_SUBDIR = "config";
|
|
2110
|
+
var CREDENTIALS_FILE = "credentials";
|
|
2111
|
+
var KEYCHAIN_SECURITY_BIN = "/usr/bin/security";
|
|
2112
|
+
var KEYCHAIN_SERVICE_PREFIX = "hasna.credentials";
|
|
2113
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44;
|
|
2114
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4;
|
|
2080
2115
|
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
2081
2116
|
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2082
2117
|
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
2083
2118
|
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
2119
|
+
var VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
2084
2120
|
function homeDir(env) {
|
|
2085
2121
|
const home = env.HOME?.trim();
|
|
2086
2122
|
return home ? home : null;
|
|
2087
2123
|
}
|
|
2088
|
-
function
|
|
2089
|
-
|
|
2124
|
+
function absoluteOverride(env, key) {
|
|
2125
|
+
const value = env[key]?.trim();
|
|
2126
|
+
return value && isAbsolute2(value) ? value : null;
|
|
2090
2127
|
}
|
|
2091
|
-
function
|
|
2128
|
+
function hasnaHomeDir(env) {
|
|
2129
|
+
const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
|
|
2130
|
+
if (override)
|
|
2131
|
+
return override;
|
|
2092
2132
|
const home = homeDir(env);
|
|
2093
|
-
|
|
2133
|
+
return home ? join4(home, HASNA_HOME_DIR) : null;
|
|
2134
|
+
}
|
|
2135
|
+
function appConfigDir(name, env) {
|
|
2136
|
+
const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
|
|
2137
|
+
if (configRoot)
|
|
2138
|
+
return join4(configRoot, name);
|
|
2139
|
+
const root = hasnaHomeDir(env);
|
|
2140
|
+
return root ? join4(root, name, CONFIG_SUBDIR) : null;
|
|
2141
|
+
}
|
|
2142
|
+
function credentialDiskSourceList(name, env, profile = null) {
|
|
2143
|
+
if (!SAFE_APP_SLUG.test(name))
|
|
2094
2144
|
return [];
|
|
2095
|
-
const
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2145
|
+
const directory = appConfigDir(name, env);
|
|
2146
|
+
if (!directory)
|
|
2147
|
+
return [];
|
|
2148
|
+
const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
|
|
2149
|
+
return [{ path: join4(directory, file), tier: "disk" }];
|
|
2150
|
+
}
|
|
2151
|
+
function credentialDiskSources(name, env) {
|
|
2152
|
+
return credentialDiskSourceList(name, env, null).map((s) => s.path);
|
|
2153
|
+
}
|
|
2154
|
+
function profileDiskSources(name, env, profile) {
|
|
2155
|
+
return credentialDiskSourceList(name, env, profile).map((s) => s.path);
|
|
2101
2156
|
}
|
|
2102
2157
|
function parseEnvFile(text) {
|
|
2103
2158
|
const values = new Map;
|
|
2159
|
+
const unusable = new Set;
|
|
2104
2160
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
2105
2161
|
const line = rawLine.trim();
|
|
2106
2162
|
if (line.length === 0 || line.startsWith("#"))
|
|
@@ -2115,38 +2171,81 @@ function parseEnvFile(text) {
|
|
|
2115
2171
|
let value = withoutExport.slice(equals + 1).trim();
|
|
2116
2172
|
const quote = value[0];
|
|
2117
2173
|
if (quote === '"' || quote === "'") {
|
|
2118
|
-
if (value.length < 2 || !value.endsWith(quote))
|
|
2174
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
2175
|
+
unusable.add(key);
|
|
2119
2176
|
continue;
|
|
2177
|
+
}
|
|
2120
2178
|
value = value.slice(1, -1);
|
|
2121
2179
|
}
|
|
2122
|
-
if (value.length === 0)
|
|
2180
|
+
if (value.trim().length === 0) {
|
|
2181
|
+
unusable.add(key);
|
|
2123
2182
|
continue;
|
|
2183
|
+
}
|
|
2184
|
+
if (values.has(key) && values.get(key) !== value)
|
|
2185
|
+
unusable.add(key);
|
|
2124
2186
|
values.set(key, value);
|
|
2125
2187
|
}
|
|
2126
|
-
return values;
|
|
2188
|
+
return { values, unusable };
|
|
2189
|
+
}
|
|
2190
|
+
function configFileModeAllowed(mode) {
|
|
2191
|
+
const permissions = mode & 4095;
|
|
2192
|
+
return permissions === 256 || permissions === 384;
|
|
2193
|
+
}
|
|
2194
|
+
function configFileReadsCoherent(before, after) {
|
|
2195
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
2127
2196
|
}
|
|
2128
2197
|
function readAppConfigFile(path) {
|
|
2129
|
-
|
|
2198
|
+
const unsafe = (reason) => {
|
|
2199
|
+
throw new CredentialFileUnsafeError(path, reason);
|
|
2200
|
+
};
|
|
2201
|
+
let fd = -1;
|
|
2130
2202
|
try {
|
|
2131
|
-
|
|
2132
|
-
|
|
2203
|
+
fd = openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
2204
|
+
} catch (error) {
|
|
2205
|
+
const code = error.code;
|
|
2206
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
2133
2207
|
return null;
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2208
|
+
if (code === "ELOOP")
|
|
2209
|
+
unsafe("the path is a symlink");
|
|
2210
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
2211
|
+
}
|
|
2212
|
+
try {
|
|
2213
|
+
const before = fstatSync(fd);
|
|
2214
|
+
if (!before.isFile())
|
|
2215
|
+
unsafe("the path is not a regular file");
|
|
2216
|
+
if (!configFileModeAllowed(before.mode)) {
|
|
2217
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
2218
|
+
}
|
|
2219
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
2220
|
+
if (uid !== undefined && before.uid !== uid)
|
|
2221
|
+
unsafe("the file is not owned by the current user");
|
|
2222
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
2223
|
+
unsafe("the file exceeds the size limit");
|
|
2224
|
+
const bytes = readFileSync2(fd);
|
|
2225
|
+
const after = fstatSync(fd);
|
|
2226
|
+
if (!configFileReadsCoherent(before, after)) {
|
|
2227
|
+
unsafe("the file changed while being read");
|
|
2228
|
+
}
|
|
2229
|
+
return parseEnvFile(bytes.toString("utf8"));
|
|
2230
|
+
} finally {
|
|
2231
|
+
if (fd !== -1)
|
|
2232
|
+
closeSync(fd);
|
|
2137
2233
|
}
|
|
2138
|
-
return parseEnvFile(text);
|
|
2139
2234
|
}
|
|
2140
2235
|
function readCredentialFile(path, apiKeyKeys) {
|
|
2141
|
-
const
|
|
2142
|
-
if (!
|
|
2236
|
+
const parsed = readAppConfigFile(path);
|
|
2237
|
+
if (!parsed)
|
|
2143
2238
|
return null;
|
|
2144
2239
|
for (const key of apiKeyKeys) {
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2240
|
+
if (parsed.unusable.has(key)) {
|
|
2241
|
+
throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
|
|
2242
|
+
}
|
|
2148
2243
|
}
|
|
2149
|
-
|
|
2244
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
2245
|
+
if (new Set(values).size > 1) {
|
|
2246
|
+
throw new CredentialFileUnsafeError(path, "credential aliases disagree");
|
|
2247
|
+
}
|
|
2248
|
+
return values[0] ?? null;
|
|
2150
2249
|
}
|
|
2151
2250
|
var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
2152
2251
|
function appConfigDiskValue(name, env, keys) {
|
|
@@ -2154,11 +2253,19 @@ function appConfigDiskValue(name, env, keys) {
|
|
|
2154
2253
|
if (wanted.length === 0)
|
|
2155
2254
|
return null;
|
|
2156
2255
|
for (const path of credentialDiskSources(name, env)) {
|
|
2157
|
-
const
|
|
2158
|
-
if (!
|
|
2256
|
+
const parsed = readAppConfigFile(path);
|
|
2257
|
+
if (!parsed)
|
|
2159
2258
|
continue;
|
|
2259
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
2260
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
2261
|
+
}
|
|
2262
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
2263
|
+
if (new Set(values).size > 1)
|
|
2264
|
+
throw new CredentialFileUnsafeError(path, "configuration aliases disagree");
|
|
2160
2265
|
for (const key of wanted) {
|
|
2161
|
-
|
|
2266
|
+
if (parsed.unusable.has(key))
|
|
2267
|
+
return { key, value: "", path, unusable: true };
|
|
2268
|
+
const value = parsed.values.get(key)?.trim();
|
|
2162
2269
|
if (value)
|
|
2163
2270
|
return { key, value, path };
|
|
2164
2271
|
}
|
|
@@ -2166,20 +2273,21 @@ function appConfigDiskValue(name, env, keys) {
|
|
|
2166
2273
|
return null;
|
|
2167
2274
|
}
|
|
2168
2275
|
function assertUsableCredential(appName, source, value) {
|
|
2276
|
+
if (VAULT_POINTER_SHAPE.test(value)) {
|
|
2277
|
+
throw new CredentialResolutionError(appName, `The credential from ${source} looks like a secrets-vault pointer (a path-shaped reference like ` + `'namespace/app/live/api_key'). A vault path is NEVER accepted as a literal API key. ` + `Use ${credentialPointerEnvKey(appName)} to resolve the key through the vault, or provide the actual key value.`, [source]);
|
|
2278
|
+
}
|
|
2169
2279
|
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
2170
2280
|
return;
|
|
2171
2281
|
throw new CredentialResolutionError(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
|
|
2172
2282
|
}
|
|
2173
2283
|
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
2174
2284
|
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
2175
|
-
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
2176
2285
|
function sealCredential(fields) {
|
|
2177
2286
|
const { apiKey } = fields;
|
|
2178
2287
|
const visible = {
|
|
2179
2288
|
tier: fields.tier,
|
|
2180
2289
|
source: fields.source,
|
|
2181
2290
|
deliberate: fields.deliberate,
|
|
2182
|
-
deprecated: fields.deprecated,
|
|
2183
2291
|
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
2184
2292
|
warning: fields.warning
|
|
2185
2293
|
};
|
|
@@ -2190,6 +2298,14 @@ function sealCredential(fields) {
|
|
|
2190
2298
|
writable: false,
|
|
2191
2299
|
configurable: false
|
|
2192
2300
|
});
|
|
2301
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
2302
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
2303
|
+
value: fields.pointerVaultKey,
|
|
2304
|
+
enumerable: false,
|
|
2305
|
+
writable: false,
|
|
2306
|
+
configurable: false
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2193
2309
|
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
2194
2310
|
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
2195
2311
|
enumerable: false,
|
|
@@ -2204,88 +2320,142 @@ function sealCredential(fields) {
|
|
|
2204
2320
|
});
|
|
2205
2321
|
return Object.freeze(sealed);
|
|
2206
2322
|
}
|
|
2207
|
-
function isSealedCredential(credential) {
|
|
2208
|
-
return credential[CREDENTIAL_SEAL] === true;
|
|
2209
|
-
}
|
|
2210
|
-
function explicitCredential(appName, apiKey) {
|
|
2211
|
-
const source = "explicit apiKey option";
|
|
2212
|
-
assertUsableCredential(appName, source, apiKey);
|
|
2213
|
-
return sealCredential({
|
|
2214
|
-
apiKey,
|
|
2215
|
-
tier: "argument",
|
|
2216
|
-
source,
|
|
2217
|
-
deliberate: true,
|
|
2218
|
-
deprecated: false,
|
|
2219
|
-
diskCandidates: [],
|
|
2220
|
-
warning: null
|
|
2221
|
-
});
|
|
2222
|
-
}
|
|
2223
|
-
function validateAndSealResolvedCredential(appName, credential) {
|
|
2224
|
-
const apiKey = credential.apiKey;
|
|
2225
|
-
assertUsableCredential(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
|
|
2226
|
-
if (!isSealedCredential(credential)) {
|
|
2227
|
-
return sealCredential({
|
|
2228
|
-
apiKey,
|
|
2229
|
-
tier: "argument",
|
|
2230
|
-
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
|
|
2231
|
-
deliberate: true,
|
|
2232
|
-
deprecated: false,
|
|
2233
|
-
diskCandidates: [],
|
|
2234
|
-
warning: null
|
|
2235
|
-
});
|
|
2236
|
-
}
|
|
2237
|
-
return sealCredential({
|
|
2238
|
-
apiKey,
|
|
2239
|
-
tier: credential.tier,
|
|
2240
|
-
source: credential.source,
|
|
2241
|
-
deliberate: credential.deliberate,
|
|
2242
|
-
deprecated: credential.deprecated,
|
|
2243
|
-
diskCandidates: credential.diskCandidates,
|
|
2244
|
-
warning: credential.warning
|
|
2245
|
-
});
|
|
2246
|
-
}
|
|
2247
2323
|
function firstEnvValue(env, keys) {
|
|
2248
2324
|
for (const key of keys) {
|
|
2325
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
2326
|
+
continue;
|
|
2249
2327
|
const value = env[key]?.trim();
|
|
2250
2328
|
if (value)
|
|
2251
2329
|
return { key, value };
|
|
2252
2330
|
}
|
|
2253
2331
|
return null;
|
|
2254
2332
|
}
|
|
2255
|
-
var
|
|
2256
|
-
function
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2333
|
+
var AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
2334
|
+
function isAmbientEnvironment(env) {
|
|
2335
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
|
|
2336
|
+
}
|
|
2337
|
+
function defaultKeychainRunner(argv) {
|
|
2338
|
+
const result = spawnSync2(KEYCHAIN_SECURITY_BIN, [...argv], {
|
|
2339
|
+
encoding: "utf8",
|
|
2340
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2341
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS
|
|
2342
|
+
});
|
|
2343
|
+
return {
|
|
2344
|
+
status: result.status,
|
|
2345
|
+
stdout: result.stdout ?? "",
|
|
2346
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
function keychainTierEnabled(env, options) {
|
|
2350
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
2351
|
+
return false;
|
|
2352
|
+
if (options.enabled !== undefined)
|
|
2353
|
+
return options.enabled;
|
|
2354
|
+
return options.run !== undefined || isAmbientEnvironment(env);
|
|
2355
|
+
}
|
|
2356
|
+
function keychainAccount(env, options) {
|
|
2357
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY]?.trim();
|
|
2358
|
+
if (station)
|
|
2359
|
+
return station;
|
|
2360
|
+
const host = (options.hostname ?? osHostname)().split(".")[0]?.trim() ?? "";
|
|
2361
|
+
if (host)
|
|
2362
|
+
return host;
|
|
2363
|
+
const user = env.USER?.trim();
|
|
2364
|
+
return user || null;
|
|
2365
|
+
}
|
|
2366
|
+
function keychainFailureHint(text) {
|
|
2367
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
2368
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
2369
|
+
return clean ? `: ${clean}` : "";
|
|
2370
|
+
}
|
|
2371
|
+
function readKeychainItem(name, env, kind, options) {
|
|
2372
|
+
if (!SAFE_APP_SLUG.test(name) || !keychainTierEnabled(env, options))
|
|
2373
|
+
return null;
|
|
2374
|
+
const account = keychainAccount(env, options);
|
|
2375
|
+
if (!account)
|
|
2376
|
+
return null;
|
|
2377
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX}.${name}.${kind}`;
|
|
2378
|
+
const source = `keychain:${service}@${account}`;
|
|
2379
|
+
const run = options.run ?? defaultKeychainRunner;
|
|
2380
|
+
let result;
|
|
2381
|
+
try {
|
|
2382
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
2383
|
+
} catch (error) {
|
|
2384
|
+
const reason = keychainFailureHint(error instanceof Error ? error.message : String(error));
|
|
2385
|
+
throw new CredentialResolutionError(name, `The Keychain lookup for ${source} could not run${reason}. A Keychain failure is never resolved ` + `around: fix the keychain, or delete the item to fall through to the credential on disk.`, [source]);
|
|
2269
2386
|
}
|
|
2387
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS)
|
|
2388
|
+
return null;
|
|
2389
|
+
if (result.status !== 0) {
|
|
2390
|
+
throw new CredentialResolutionError(name, `The Keychain lookup for ${source} failed (security exited ` + `${result.status ?? "without a status"}${keychainFailureHint(result.stderr)}). A Keychain item that ` + `exists but cannot be read is never resolved around: unlock the keychain, run from a session that ` + `may use it, or delete the item to fall through to the credential on disk.`, [source]);
|
|
2391
|
+
}
|
|
2392
|
+
const value = result.stdout.trim();
|
|
2393
|
+
if (!value) {
|
|
2394
|
+
throw new CredentialResolutionError(name, `${source} exists but holds an empty value; a declared item never falls through to another ` + `identity. Store a value in it or delete the item.`, [source]);
|
|
2395
|
+
}
|
|
2396
|
+
return { value, source };
|
|
2397
|
+
}
|
|
2398
|
+
function keychainConfigValue(name, env, options = {}) {
|
|
2399
|
+
return readKeychainItem(name, env, "api-url", options);
|
|
2400
|
+
}
|
|
2401
|
+
function snapshotClientEnvironment(name, env) {
|
|
2402
|
+
const keys = clientTransportEnvKeys(name);
|
|
2403
|
+
const ambient = isAmbientEnvironment(env);
|
|
2404
|
+
const snapshot = Object.create(null);
|
|
2405
|
+
for (const key of [
|
|
2406
|
+
...keys.apiUrlKeys,
|
|
2407
|
+
...keys.apiKeyKeys,
|
|
2408
|
+
credentialOverrideEnvKey(name),
|
|
2409
|
+
credentialPointerEnvKey(name),
|
|
2410
|
+
CREDENTIAL_PROFILE_ENV_KEY,
|
|
2411
|
+
"HOME",
|
|
2412
|
+
HASNA_HOME_ENV_KEY,
|
|
2413
|
+
HASNA_CONFIG_HOME_ENV_KEY,
|
|
2414
|
+
KEYCHAIN_STATION_ENV_KEY,
|
|
2415
|
+
"USER"
|
|
2416
|
+
]) {
|
|
2417
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
2418
|
+
if (!descriptor)
|
|
2419
|
+
continue;
|
|
2420
|
+
if (!("value" in descriptor)) {
|
|
2421
|
+
throw new CredentialResolutionError(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
2422
|
+
}
|
|
2423
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
2424
|
+
throw new CredentialResolutionError(name, `${key} must be a string data property.`, [key]);
|
|
2425
|
+
}
|
|
2426
|
+
snapshot[key] = descriptor.value;
|
|
2427
|
+
}
|
|
2428
|
+
if (ambient) {
|
|
2429
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
|
|
2430
|
+
value: true,
|
|
2431
|
+
enumerable: false,
|
|
2432
|
+
writable: false,
|
|
2433
|
+
configurable: false
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
return Object.freeze(snapshot);
|
|
2270
2437
|
}
|
|
2271
2438
|
function resolveCredential(name, env, options = {}) {
|
|
2439
|
+
env = snapshotClientEnvironment(name, env);
|
|
2272
2440
|
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
2273
2441
|
const diskPaths = credentialDiskSources(name, env);
|
|
2274
|
-
|
|
2275
|
-
|
|
2442
|
+
if (options.apiKey !== undefined) {
|
|
2443
|
+
const explicitKey = options.apiKey.trim();
|
|
2444
|
+
if (!explicitKey) {
|
|
2445
|
+
throw new CredentialResolutionError(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
2446
|
+
}
|
|
2276
2447
|
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
2277
2448
|
return sealCredential({
|
|
2278
2449
|
apiKey: explicitKey,
|
|
2279
2450
|
tier: "argument",
|
|
2280
2451
|
source: "explicit apiKey argument",
|
|
2281
2452
|
deliberate: true,
|
|
2282
|
-
deprecated: false,
|
|
2283
2453
|
diskCandidates: diskPaths,
|
|
2284
2454
|
warning: null
|
|
2285
2455
|
});
|
|
2286
2456
|
}
|
|
2287
2457
|
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
2288
|
-
const overrideRaw = env[overrideKeyName];
|
|
2458
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
2289
2459
|
if (overrideRaw !== undefined) {
|
|
2290
2460
|
const override = overrideRaw.trim();
|
|
2291
2461
|
if (!override) {
|
|
@@ -2297,12 +2467,38 @@ function resolveCredential(name, env, options = {}) {
|
|
|
2297
2467
|
tier: "override",
|
|
2298
2468
|
source: overrideKeyName,
|
|
2299
2469
|
deliberate: true,
|
|
2300
|
-
deprecated: false,
|
|
2301
2470
|
diskCandidates: diskPaths,
|
|
2302
2471
|
warning: null
|
|
2303
2472
|
});
|
|
2304
2473
|
}
|
|
2305
|
-
const
|
|
2474
|
+
const pointerKeyName = credentialPointerEnvKey(name);
|
|
2475
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
2476
|
+
if (pointerRaw !== undefined) {
|
|
2477
|
+
const pointer = pointerRaw.trim();
|
|
2478
|
+
if (!pointer) {
|
|
2479
|
+
throw new CredentialResolutionError(name, `${pointerKeyName} is set but empty. It is a deliberate vault pointer, so it is not resolved around: ` + `either give it a vault item key or unset it to fall back to the credential on disk.`, [pointerKeyName]);
|
|
2480
|
+
}
|
|
2481
|
+
if (!VAULT_POINTER_SHAPE.test(pointer)) {
|
|
2482
|
+
throw new CredentialResolutionError(name, `${pointerKeyName} must name a vault ITEM KEY (a path-shaped reference like ` + `'namespace/app/live/api_key'), not a credential value. A pointer that carries a literal is refused.`, [pointerKeyName]);
|
|
2483
|
+
}
|
|
2484
|
+
return sealCredential({
|
|
2485
|
+
apiKey: "",
|
|
2486
|
+
pointerVaultKey: pointer,
|
|
2487
|
+
tier: "pointer",
|
|
2488
|
+
source: pointerKeyName,
|
|
2489
|
+
deliberate: true,
|
|
2490
|
+
diskCandidates: diskPaths,
|
|
2491
|
+
warning: null
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
2495
|
+
throw new CredentialResolutionError(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
2496
|
+
}
|
|
2497
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY) ? env[CREDENTIAL_PROFILE_ENV_KEY] : undefined;
|
|
2498
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
2499
|
+
throw new CredentialResolutionError(name, `${CREDENTIAL_PROFILE_ENV_KEY} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY]);
|
|
2500
|
+
}
|
|
2501
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
2306
2502
|
if (profile) {
|
|
2307
2503
|
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
2308
2504
|
if (!SAFE_PROFILE.test(profile)) {
|
|
@@ -2318,7 +2514,6 @@ function resolveCredential(name, env, options = {}) {
|
|
|
2318
2514
|
tier: "profile",
|
|
2319
2515
|
source: path,
|
|
2320
2516
|
deliberate: true,
|
|
2321
|
-
deprecated: false,
|
|
2322
2517
|
diskCandidates: paths,
|
|
2323
2518
|
warning: null
|
|
2324
2519
|
});
|
|
@@ -2326,51 +2521,67 @@ function resolveCredential(name, env, options = {}) {
|
|
|
2326
2521
|
}
|
|
2327
2522
|
throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY}.`, paths);
|
|
2328
2523
|
}
|
|
2329
|
-
const
|
|
2524
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
2525
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
2526
|
+
if (blankEnv) {
|
|
2527
|
+
throw new CredentialResolutionError(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
2528
|
+
}
|
|
2529
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
2530
|
+
throw new CredentialResolutionError(name, `${definedEnvEntries.map((entry) => entry.key).join(" and ")} disagree; credential aliases must be identical or only one may be set.`, definedEnvEntries.map((entry) => entry.key));
|
|
2531
|
+
}
|
|
2532
|
+
const envHit = firstEnvValue(env, apiKeyKeys);
|
|
2533
|
+
const keychainHit = readKeychainItem(name, env, "api-key", options.keychain ?? {});
|
|
2534
|
+
if (keychainHit) {
|
|
2535
|
+
assertUsableCredential(name, keychainHit.source, keychainHit.value);
|
|
2536
|
+
const warning = envHit && envHit.value !== keychainHit.value ? `Credential sources disagree for '${name}': ${keychainHit.source} and ${envHit.key} hold ` + `different keys. ${keychainHit.source} wins, because the Keychain is re-read on every call while ` + `an environment variable is a snapshot. Reconcile them \u2014 a rotation that updated only one leaves ` + `the other to fail 401 wherever it is loaded first.` : null;
|
|
2537
|
+
return sealCredential({
|
|
2538
|
+
apiKey: keychainHit.value,
|
|
2539
|
+
tier: "keychain",
|
|
2540
|
+
source: keychainHit.source,
|
|
2541
|
+
deliberate: false,
|
|
2542
|
+
diskCandidates: diskPaths,
|
|
2543
|
+
warning
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
const diskSourceList = credentialDiskSourceList(name, env, null);
|
|
2547
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
2330
2548
|
if (diskHits.length > 0) {
|
|
2331
2549
|
const winner = diskHits[0];
|
|
2332
|
-
assertUsableCredential(name, winner.path, winner.value);
|
|
2550
|
+
assertUsableCredential(name, winner.src.path, winner.value);
|
|
2333
2551
|
const divergentSources = [
|
|
2334
|
-
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
|
|
2335
|
-
...
|
|
2336
|
-
const legacyHit = firstEnvValue(env, apiKeyKeys);
|
|
2337
|
-
return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
|
|
2338
|
-
})()
|
|
2552
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
2553
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
2339
2554
|
];
|
|
2340
|
-
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
|
|
2555
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.src.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.src.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
|
|
2341
2556
|
return sealCredential({
|
|
2342
2557
|
apiKey: winner.value,
|
|
2343
|
-
tier:
|
|
2344
|
-
source: winner.path,
|
|
2558
|
+
tier: winner.src.tier,
|
|
2559
|
+
source: winner.src.path,
|
|
2345
2560
|
deliberate: false,
|
|
2346
|
-
deprecated: false,
|
|
2347
2561
|
diskCandidates: diskPaths,
|
|
2348
2562
|
warning
|
|
2349
2563
|
});
|
|
2350
2564
|
}
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
assertUsableCredential(name, legacy.key, legacy.value);
|
|
2354
|
-
const where = diskPaths.length > 0 ? `Put the current key in ${diskPaths[0]} \u2014 it is re-read on every call, so rotations take effect immediately.` : `This environment has no HOME, so no credential file could be consulted at all; the disk tier is ` + `unavailable here and this process will keep using the environment snapshot.`;
|
|
2355
|
-
const message = `[${name}] DEPRECATED: the API key came from ${legacy.key} in this process's environment. ` + `Environment variables are a snapshot taken when this process started, so a shell that started ` + `before a key rotation keeps using the old key until it exits. ${where}`;
|
|
2356
|
-
const sink = options.onDeprecation ?? defaultDeprecationSink;
|
|
2357
|
-
const notified = deprecationNotified();
|
|
2358
|
-
if (!notified.has(name)) {
|
|
2359
|
-
notified.add(name);
|
|
2360
|
-
sink(message);
|
|
2361
|
-
}
|
|
2565
|
+
if (envHit) {
|
|
2566
|
+
assertUsableCredential(name, envHit.key, envHit.value);
|
|
2362
2567
|
return sealCredential({
|
|
2363
|
-
apiKey:
|
|
2364
|
-
tier: "
|
|
2365
|
-
source:
|
|
2568
|
+
apiKey: envHit.value,
|
|
2569
|
+
tier: "env",
|
|
2570
|
+
source: envHit.key,
|
|
2366
2571
|
deliberate: false,
|
|
2367
|
-
deprecated: true,
|
|
2368
2572
|
diskCandidates: diskPaths,
|
|
2369
|
-
warning:
|
|
2573
|
+
warning: null
|
|
2370
2574
|
});
|
|
2371
2575
|
}
|
|
2372
2576
|
return null;
|
|
2373
2577
|
}
|
|
2578
|
+
var SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
|
|
2579
|
+
var requireSecretsSdk = createRequire(import.meta.url);
|
|
2580
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com";
|
|
2581
|
+
var DEFAULT_AUTHORITY_SOURCE = "default";
|
|
2582
|
+
function defaultFleetGatewayBaseUrl(name) {
|
|
2583
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN}/${validateAppSlug(name)}`;
|
|
2584
|
+
}
|
|
2374
2585
|
var ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
2375
2586
|
var DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2376
2587
|
function isValidDnsDomain(value) {
|
|
@@ -2379,23 +2590,835 @@ function isValidDnsDomain(value) {
|
|
|
2379
2590
|
}
|
|
2380
2591
|
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
|
|
2381
2592
|
}
|
|
2382
|
-
function
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2593
|
+
function validateAppSlug(name) {
|
|
2594
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN.test(name)) {
|
|
2595
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
2596
|
+
}
|
|
2597
|
+
return name;
|
|
2598
|
+
}
|
|
2599
|
+
function rawAuthority(value) {
|
|
2600
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
2601
|
+
if (!match)
|
|
2602
|
+
throw new Error("API URL must be absolute.");
|
|
2603
|
+
const afterScheme = value.slice(match[0].length);
|
|
2604
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
2605
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
2606
|
+
if (!authority)
|
|
2607
|
+
throw new Error("API URL must include a hostname.");
|
|
2608
|
+
return authority;
|
|
2609
|
+
}
|
|
2610
|
+
function assertCanonicalPort(port) {
|
|
2611
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
2612
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2613
|
+
}
|
|
2614
|
+
const numericPort = Number(port);
|
|
2615
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
2616
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
function canonicalAuthorityHostname(authority) {
|
|
2620
|
+
let rawHostname;
|
|
2621
|
+
if (authority.startsWith("[")) {
|
|
2622
|
+
const closingBracket = authority.indexOf("]");
|
|
2623
|
+
if (closingBracket === -1) {
|
|
2624
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
2625
|
+
}
|
|
2626
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
2627
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
2628
|
+
if (portSuffix) {
|
|
2629
|
+
if (!portSuffix.startsWith(":")) {
|
|
2630
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
2631
|
+
}
|
|
2632
|
+
assertCanonicalPort(portSuffix.slice(1));
|
|
2633
|
+
}
|
|
2634
|
+
if (isIP(rawHostname.slice(1, -1)) !== 6) {
|
|
2635
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
2636
|
+
}
|
|
2637
|
+
} else {
|
|
2638
|
+
const firstColon = authority.indexOf(":");
|
|
2639
|
+
const lastColon = authority.lastIndexOf(":");
|
|
2640
|
+
if (firstColon !== lastColon) {
|
|
2641
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
2642
|
+
}
|
|
2643
|
+
if (lastColon !== -1) {
|
|
2644
|
+
const port = authority.slice(lastColon + 1);
|
|
2645
|
+
assertCanonicalPort(port);
|
|
2646
|
+
rawHostname = authority.slice(0, lastColon);
|
|
2647
|
+
} else {
|
|
2648
|
+
rawHostname = authority;
|
|
2649
|
+
}
|
|
2650
|
+
const ipVersion = isIP(rawHostname);
|
|
2651
|
+
const numericAddressParts = rawHostname.split(".");
|
|
2652
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
2653
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain(rawHostname.toLowerCase())) {
|
|
2654
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
return rawHostname.toLowerCase();
|
|
2658
|
+
}
|
|
2659
|
+
function isDeliberateLoopbackHttpAuthority(authority) {
|
|
2660
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
2661
|
+
}
|
|
2662
|
+
function toV1BaseUrl(apiUrl) {
|
|
2663
|
+
if (ASCII_CONTROL_PATTERN.test(apiUrl)) {
|
|
2664
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
2665
|
+
}
|
|
2666
|
+
const input = apiUrl.trim();
|
|
2667
|
+
const authority = rawAuthority(input);
|
|
2668
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
2669
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
2670
|
+
}
|
|
2671
|
+
const canonicalHostname = canonicalAuthorityHostname(authority);
|
|
2672
|
+
const url = new URL(input);
|
|
2673
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2674
|
+
throw new Error("API URL must use http or https.");
|
|
2675
|
+
}
|
|
2676
|
+
if (url.username || url.password) {
|
|
2677
|
+
throw new Error("API URL must not include credentials.");
|
|
2678
|
+
}
|
|
2679
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
2680
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
2681
|
+
}
|
|
2682
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
2683
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
2684
|
+
}
|
|
2685
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
2686
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
2687
|
+
}
|
|
2688
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority(authority)) {
|
|
2689
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
2690
|
+
}
|
|
2691
|
+
if (url.search || url.hash) {
|
|
2692
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
2693
|
+
}
|
|
2694
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
2695
|
+
if (path.endsWith("/v1"))
|
|
2696
|
+
path = path.slice(0, -"/v1".length);
|
|
2697
|
+
url.pathname = `${path}/v1`;
|
|
2698
|
+
return url.toString().replace(/\/+$/, "");
|
|
2699
|
+
}
|
|
2700
|
+
class ClientTransportConfigurationError extends Error {
|
|
2701
|
+
appName;
|
|
2702
|
+
sources;
|
|
2703
|
+
constructor(appName, message, sources = []) {
|
|
2704
|
+
super(message);
|
|
2705
|
+
this.name = "ClientTransportConfigurationError";
|
|
2706
|
+
this.appName = appName;
|
|
2707
|
+
this.sources = Object.freeze([...sources]);
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
function resolveClientTransportSnapshot(name, env = process.env, options = {}) {
|
|
2711
|
+
env = snapshotClientEnvironment(name, env);
|
|
2712
|
+
const keys = clientTransportEnvKeys(name);
|
|
2713
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
2714
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
2715
|
+
if (blankUrl) {
|
|
2716
|
+
throw new ClientTransportConfigurationError(name, `${blankUrl.key} is set but blank; public clients require an explicit HTTPS API URL and never select local storage.`, [blankUrl.key]);
|
|
2717
|
+
}
|
|
2718
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN.test(entry.raw));
|
|
2719
|
+
if (controlledUrl) {
|
|
2720
|
+
throw new ClientTransportConfigurationError(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
2721
|
+
}
|
|
2722
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
2723
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
2724
|
+
throw new ClientTransportConfigurationError(name, `${usableUrlEntries.map((entry) => entry.key).join(" and ")} disagree; client authority aliases must be identical or only one may be set.`, usableUrlEntries.map((entry) => entry.key));
|
|
2725
|
+
}
|
|
2726
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
2727
|
+
const keychainUrlHit = keychainConfigValue(name, env, options.credentials?.keychain);
|
|
2728
|
+
const diskConfigUrlHit = appConfigDiskValue(name, env, keys.apiUrlKeys);
|
|
2729
|
+
if (diskConfigUrlHit?.unusable) {
|
|
2730
|
+
throw new ClientTransportConfigurationError(name, `${diskConfigUrlHit.key} in ${diskConfigUrlHit.path} is declared but blank or malformed; public clients require a valid HTTPS service authority.`, [diskConfigUrlHit.path]);
|
|
2731
|
+
}
|
|
2732
|
+
const urlCandidates = [
|
|
2733
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
2734
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
2735
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
2736
|
+
];
|
|
2737
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
2738
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
2739
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
2740
|
+
throw new ClientTransportConfigurationError(name, `${configuredUrl.key} and ${divergentUrls.map((candidate) => candidate.key).join(" and ")} select different service authorities; refusing to send a credential written for one authority to the other.`, urlCandidates.map((candidate) => candidate.key));
|
|
2741
|
+
}
|
|
2742
|
+
const warnings = [];
|
|
2743
|
+
if (configuredUrl && !envUrlHit) {
|
|
2744
|
+
warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${configuredUrl.key} was used, so this client connects to the server. ` + `Keep that entry aligned with the intended service authority.`);
|
|
2745
|
+
}
|
|
2746
|
+
const credential = resolveCredential(name, env, options.credentials);
|
|
2747
|
+
if (!credential) {
|
|
2748
|
+
const diskHint = credentialDiskSourcesForMessage(name, env);
|
|
2749
|
+
const lead = configuredUrl ? `${configuredUrl.key} selects the HTTP server for '${name}', but no API key could be resolved` : `${keys.apiUrlKeys[0]} is not set and no API key could be resolved for '${name}'; a credential is required before the default fleet gateway authority applies`;
|
|
2750
|
+
warnings.push(`${lead}; refusing to create an unauthenticated client \u2014 public clients never fall back to SQLite or another local store. ` + `Looked in the Keychain (macOS only), then for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
|
|
2751
|
+
throw new ClientTransportConfigurationError(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
2752
|
+
}
|
|
2753
|
+
if (credential.warning)
|
|
2754
|
+
warnings.push(credential.warning);
|
|
2755
|
+
let urlHit;
|
|
2756
|
+
if (configuredUrl) {
|
|
2757
|
+
urlHit = configuredUrl;
|
|
2758
|
+
} else {
|
|
2759
|
+
try {
|
|
2760
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE, value: defaultFleetGatewayBaseUrl(name) };
|
|
2761
|
+
} catch (error) {
|
|
2762
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2763
|
+
throw new ClientTransportConfigurationError(name, `No ${keys.apiUrlKeys[0]} is configured and the default fleet gateway authority cannot be composed for '${name}': ${message}`, [keys.apiUrlKeys[0]]);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
const apiUrlSource = urlHit.key;
|
|
2767
|
+
let baseUrl;
|
|
2768
|
+
try {
|
|
2769
|
+
baseUrl = toV1BaseUrl(urlHit.value);
|
|
2770
|
+
} catch (error) {
|
|
2771
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2772
|
+
throw new ClientTransportConfigurationError(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
2773
|
+
}
|
|
2774
|
+
return {
|
|
2775
|
+
resolution: {
|
|
2776
|
+
transport: "http",
|
|
2777
|
+
transportSource: urlHit.key,
|
|
2778
|
+
baseUrl,
|
|
2779
|
+
apiUrlSource,
|
|
2780
|
+
apiKeyPresent: true,
|
|
2781
|
+
apiKeySource: credential.source,
|
|
2782
|
+
apiKeyTier: credential.tier,
|
|
2783
|
+
misconfigured: false,
|
|
2784
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
2785
|
+
},
|
|
2786
|
+
credential
|
|
2787
|
+
};
|
|
2788
|
+
}
|
|
2789
|
+
function resolveClientTransport(name, env = process.env, options = {}) {
|
|
2790
|
+
return resolveClientTransportSnapshot(name, env, options).resolution;
|
|
2791
|
+
}
|
|
2792
|
+
function credentialDiskSourcesForMessage(name, env) {
|
|
2793
|
+
const paths = credentialDiskSources(name, env);
|
|
2794
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
2795
|
+
}
|
|
2796
|
+
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
2797
|
+
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
2798
|
+
"host",
|
|
2799
|
+
":authority",
|
|
2800
|
+
"forwarded",
|
|
2801
|
+
"x-forwarded-host",
|
|
2802
|
+
"x-original-host"
|
|
2803
|
+
]);
|
|
2804
|
+
|
|
2805
|
+
// ../contracts/dist/client/storage.js
|
|
2806
|
+
import { isIP as isIP2 } from "net";
|
|
2807
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2808
|
+
import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync3 } from "fs";
|
|
2809
|
+
import { O_NOFOLLOW as O_NOFOLLOW2, O_NONBLOCK as O_NONBLOCK2, O_RDONLY as O_RDONLY2 } from "constants";
|
|
2810
|
+
import { createRequire as createRequire2 } from "module";
|
|
2811
|
+
import { hostname as osHostname2 } from "os";
|
|
2812
|
+
import { isAbsolute as isAbsolute3, join as join5 } from "path";
|
|
2813
|
+
function envToken2(name) {
|
|
2814
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
2815
|
+
}
|
|
2816
|
+
function clientTransportEnvKeys2(name) {
|
|
2817
|
+
const envSegment = envToken2(name);
|
|
2818
|
+
return {
|
|
2819
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
2820
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
2821
|
+
};
|
|
2822
|
+
}
|
|
2823
|
+
function credentialOverrideEnvKey2(name) {
|
|
2824
|
+
return `HASNA_${envToken2(name)}_API_KEY_OVERRIDE`;
|
|
2825
|
+
}
|
|
2826
|
+
var CREDENTIAL_PROFILE_ENV_KEY2 = "HASNA_PROFILE";
|
|
2827
|
+
function credentialPointerEnvKey2(name) {
|
|
2828
|
+
return `HASNA_${envToken2(name)}_API_KEY_REF`;
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
class CredentialResolutionError2 extends Error {
|
|
2832
|
+
appName;
|
|
2833
|
+
attempted;
|
|
2834
|
+
constructor(appName, message, attempted) {
|
|
2835
|
+
super(message);
|
|
2836
|
+
this.name = "CredentialResolutionError";
|
|
2837
|
+
this.appName = appName;
|
|
2838
|
+
this.attempted = attempted;
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
class CredentialFileUnsafeError2 extends Error {
|
|
2843
|
+
path;
|
|
2844
|
+
constructor(path, reason) {
|
|
2845
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
2846
|
+
this.name = "CredentialFileUnsafeError";
|
|
2847
|
+
this.path = path;
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
var HASNA_HOME_ENV_KEY2 = "HASNA_HOME";
|
|
2851
|
+
var HASNA_CONFIG_HOME_ENV_KEY2 = "HASNA_CONFIG_HOME";
|
|
2852
|
+
var KEYCHAIN_STATION_ENV_KEY2 = "HASNA_STATION";
|
|
2853
|
+
var HASNA_HOME_DIR2 = ".hasna";
|
|
2854
|
+
var CONFIG_SUBDIR2 = "config";
|
|
2855
|
+
var CREDENTIALS_FILE2 = "credentials";
|
|
2856
|
+
var KEYCHAIN_SECURITY_BIN2 = "/usr/bin/security";
|
|
2857
|
+
var KEYCHAIN_SERVICE_PREFIX2 = "hasna.credentials";
|
|
2858
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS2 = 44;
|
|
2859
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS2 = 1e4;
|
|
2860
|
+
var MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
|
|
2861
|
+
var SAFE_APP_SLUG2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
2862
|
+
var SAFE_PROFILE2 = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
2863
|
+
var ILLEGAL_IN_HEADER_VALUE2 = /[^\t\x20-\x7e]/;
|
|
2864
|
+
var VAULT_POINTER_SHAPE2 = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
2865
|
+
function homeDir2(env) {
|
|
2866
|
+
const home = env.HOME?.trim();
|
|
2867
|
+
return home ? home : null;
|
|
2868
|
+
}
|
|
2869
|
+
function absoluteOverride2(env, key) {
|
|
2870
|
+
const value = env[key]?.trim();
|
|
2871
|
+
return value && isAbsolute3(value) ? value : null;
|
|
2872
|
+
}
|
|
2873
|
+
function hasnaHomeDir2(env) {
|
|
2874
|
+
const override = absoluteOverride2(env, HASNA_HOME_ENV_KEY2);
|
|
2875
|
+
if (override)
|
|
2876
|
+
return override;
|
|
2877
|
+
const home = homeDir2(env);
|
|
2878
|
+
return home ? join5(home, HASNA_HOME_DIR2) : null;
|
|
2879
|
+
}
|
|
2880
|
+
function appConfigDir2(name, env) {
|
|
2881
|
+
const configRoot = absoluteOverride2(env, HASNA_CONFIG_HOME_ENV_KEY2);
|
|
2882
|
+
if (configRoot)
|
|
2883
|
+
return join5(configRoot, name);
|
|
2884
|
+
const root = hasnaHomeDir2(env);
|
|
2885
|
+
return root ? join5(root, name, CONFIG_SUBDIR2) : null;
|
|
2886
|
+
}
|
|
2887
|
+
function credentialDiskSourceList2(name, env, profile = null) {
|
|
2888
|
+
if (!SAFE_APP_SLUG2.test(name))
|
|
2889
|
+
return [];
|
|
2890
|
+
const directory = appConfigDir2(name, env);
|
|
2891
|
+
if (!directory)
|
|
2892
|
+
return [];
|
|
2893
|
+
const file = profile ? `${CREDENTIALS_FILE2}-${profile}` : CREDENTIALS_FILE2;
|
|
2894
|
+
return [{ path: join5(directory, file), tier: "disk" }];
|
|
2895
|
+
}
|
|
2896
|
+
function credentialDiskSources2(name, env) {
|
|
2897
|
+
return credentialDiskSourceList2(name, env, null).map((s) => s.path);
|
|
2898
|
+
}
|
|
2899
|
+
function profileDiskSources2(name, env, profile) {
|
|
2900
|
+
return credentialDiskSourceList2(name, env, profile).map((s) => s.path);
|
|
2901
|
+
}
|
|
2902
|
+
function parseEnvFile2(text) {
|
|
2903
|
+
const values = new Map;
|
|
2904
|
+
const unusable = new Set;
|
|
2905
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
2906
|
+
const line = rawLine.trim();
|
|
2907
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
2908
|
+
continue;
|
|
2909
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
2910
|
+
const equals = withoutExport.indexOf("=");
|
|
2911
|
+
if (equals <= 0)
|
|
2912
|
+
continue;
|
|
2913
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
2914
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
2915
|
+
continue;
|
|
2916
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
2917
|
+
const quote = value[0];
|
|
2918
|
+
if (quote === '"' || quote === "'") {
|
|
2919
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
2920
|
+
unusable.add(key);
|
|
2921
|
+
continue;
|
|
2922
|
+
}
|
|
2923
|
+
value = value.slice(1, -1);
|
|
2924
|
+
}
|
|
2925
|
+
if (value.trim().length === 0) {
|
|
2926
|
+
unusable.add(key);
|
|
2927
|
+
continue;
|
|
2928
|
+
}
|
|
2929
|
+
if (values.has(key) && values.get(key) !== value)
|
|
2930
|
+
unusable.add(key);
|
|
2931
|
+
values.set(key, value);
|
|
2932
|
+
}
|
|
2933
|
+
return { values, unusable };
|
|
2934
|
+
}
|
|
2935
|
+
function configFileModeAllowed2(mode) {
|
|
2936
|
+
const permissions = mode & 4095;
|
|
2937
|
+
return permissions === 256 || permissions === 384;
|
|
2938
|
+
}
|
|
2939
|
+
function configFileReadsCoherent2(before, after) {
|
|
2940
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
2941
|
+
}
|
|
2942
|
+
function readAppConfigFile2(path) {
|
|
2943
|
+
const unsafe = (reason) => {
|
|
2944
|
+
throw new CredentialFileUnsafeError2(path, reason);
|
|
2945
|
+
};
|
|
2946
|
+
let fd = -1;
|
|
2947
|
+
try {
|
|
2948
|
+
fd = openSync2(path, O_RDONLY2 | O_NOFOLLOW2 | O_NONBLOCK2);
|
|
2949
|
+
} catch (error) {
|
|
2950
|
+
const code = error.code;
|
|
2951
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
2952
|
+
return null;
|
|
2953
|
+
if (code === "ELOOP")
|
|
2954
|
+
unsafe("the path is a symlink");
|
|
2955
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
2956
|
+
}
|
|
2957
|
+
try {
|
|
2958
|
+
const before = fstatSync2(fd);
|
|
2959
|
+
if (!before.isFile())
|
|
2960
|
+
unsafe("the path is not a regular file");
|
|
2961
|
+
if (!configFileModeAllowed2(before.mode)) {
|
|
2962
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
2963
|
+
}
|
|
2964
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
2965
|
+
if (uid !== undefined && before.uid !== uid)
|
|
2966
|
+
unsafe("the file is not owned by the current user");
|
|
2967
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES2)
|
|
2968
|
+
unsafe("the file exceeds the size limit");
|
|
2969
|
+
const bytes = readFileSync3(fd);
|
|
2970
|
+
const after = fstatSync2(fd);
|
|
2971
|
+
if (!configFileReadsCoherent2(before, after)) {
|
|
2972
|
+
unsafe("the file changed while being read");
|
|
2973
|
+
}
|
|
2974
|
+
return parseEnvFile2(bytes.toString("utf8"));
|
|
2975
|
+
} finally {
|
|
2976
|
+
if (fd !== -1)
|
|
2977
|
+
closeSync2(fd);
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
function readCredentialFile2(path, apiKeyKeys) {
|
|
2981
|
+
const parsed = readAppConfigFile2(path);
|
|
2982
|
+
if (!parsed)
|
|
2983
|
+
return null;
|
|
2984
|
+
for (const key of apiKeyKeys) {
|
|
2985
|
+
if (parsed.unusable.has(key)) {
|
|
2986
|
+
throw new CredentialFileUnsafeError2(path, `${key} is declared but blank or malformed`);
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
2990
|
+
if (new Set(values).size > 1) {
|
|
2991
|
+
throw new CredentialFileUnsafeError2(path, "credential aliases disagree");
|
|
2992
|
+
}
|
|
2993
|
+
return values[0] ?? null;
|
|
2994
|
+
}
|
|
2995
|
+
var CREDENTIAL_SHAPED_KEY2 = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
2996
|
+
function appConfigDiskValue2(name, env, keys) {
|
|
2997
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY2.test(key));
|
|
2998
|
+
if (wanted.length === 0)
|
|
2999
|
+
return null;
|
|
3000
|
+
for (const path of credentialDiskSources2(name, env)) {
|
|
3001
|
+
const parsed = readAppConfigFile2(path);
|
|
3002
|
+
if (!parsed)
|
|
3003
|
+
continue;
|
|
3004
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
3005
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
3006
|
+
}
|
|
3007
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
3008
|
+
if (new Set(values).size > 1)
|
|
3009
|
+
throw new CredentialFileUnsafeError2(path, "configuration aliases disagree");
|
|
3010
|
+
for (const key of wanted) {
|
|
3011
|
+
if (parsed.unusable.has(key))
|
|
3012
|
+
return { key, value: "", path, unusable: true };
|
|
3013
|
+
const value = parsed.values.get(key)?.trim();
|
|
3014
|
+
if (value)
|
|
3015
|
+
return { key, value, path };
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
return null;
|
|
3019
|
+
}
|
|
3020
|
+
function assertUsableCredential2(appName, source, value) {
|
|
3021
|
+
if (VAULT_POINTER_SHAPE2.test(value)) {
|
|
3022
|
+
throw new CredentialResolutionError2(appName, `The credential from ${source} looks like a secrets-vault pointer (a path-shaped reference like ` + `'namespace/app/live/api_key'). A vault path is NEVER accepted as a literal API key. ` + `Use ${credentialPointerEnvKey2(appName)} to resolve the key through the vault, or provide the actual key value.`, [source]);
|
|
3023
|
+
}
|
|
3024
|
+
if (!ILLEGAL_IN_HEADER_VALUE2.test(value))
|
|
3025
|
+
return;
|
|
3026
|
+
throw new CredentialResolutionError2(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
|
|
3027
|
+
}
|
|
3028
|
+
var INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
|
|
3029
|
+
var CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
|
|
3030
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
3031
|
+
function sealCredential2(fields) {
|
|
3032
|
+
const { apiKey } = fields;
|
|
3033
|
+
const visible = {
|
|
3034
|
+
tier: fields.tier,
|
|
3035
|
+
source: fields.source,
|
|
3036
|
+
deliberate: fields.deliberate,
|
|
3037
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
3038
|
+
warning: fields.warning
|
|
3039
|
+
};
|
|
3040
|
+
const sealed = { ...visible };
|
|
3041
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
3042
|
+
value: apiKey,
|
|
3043
|
+
enumerable: false,
|
|
3044
|
+
writable: false,
|
|
3045
|
+
configurable: false
|
|
3046
|
+
});
|
|
3047
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
3048
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
3049
|
+
value: fields.pointerVaultKey,
|
|
3050
|
+
enumerable: false,
|
|
3051
|
+
writable: false,
|
|
3052
|
+
configurable: false
|
|
3053
|
+
});
|
|
3054
|
+
}
|
|
3055
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM2, {
|
|
3056
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
3057
|
+
enumerable: false,
|
|
3058
|
+
writable: false,
|
|
3059
|
+
configurable: false
|
|
3060
|
+
});
|
|
3061
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL2, {
|
|
3062
|
+
value: true,
|
|
3063
|
+
enumerable: false,
|
|
3064
|
+
writable: false,
|
|
3065
|
+
configurable: false
|
|
3066
|
+
});
|
|
3067
|
+
return Object.freeze(sealed);
|
|
3068
|
+
}
|
|
3069
|
+
function isSealedCredential(credential) {
|
|
3070
|
+
return credential[CREDENTIAL_SEAL2] === true;
|
|
3071
|
+
}
|
|
3072
|
+
function explicitCredential(appName, apiKey) {
|
|
3073
|
+
const source = "explicit apiKey option";
|
|
3074
|
+
assertUsableCredential2(appName, source, apiKey);
|
|
3075
|
+
return sealCredential2({
|
|
3076
|
+
apiKey,
|
|
3077
|
+
tier: "argument",
|
|
3078
|
+
source,
|
|
3079
|
+
deliberate: true,
|
|
3080
|
+
diskCandidates: [],
|
|
3081
|
+
warning: null
|
|
3082
|
+
});
|
|
3083
|
+
}
|
|
3084
|
+
function validateAndSealResolvedCredential(appName, credential) {
|
|
3085
|
+
const apiKey = credential.apiKey;
|
|
3086
|
+
assertUsableCredential2(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
|
|
3087
|
+
if (!isSealedCredential(credential)) {
|
|
3088
|
+
return sealCredential2({
|
|
3089
|
+
apiKey,
|
|
3090
|
+
tier: "argument",
|
|
3091
|
+
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
|
|
3092
|
+
deliberate: true,
|
|
3093
|
+
diskCandidates: [],
|
|
3094
|
+
warning: null
|
|
3095
|
+
});
|
|
3096
|
+
}
|
|
3097
|
+
return sealCredential2({
|
|
3098
|
+
apiKey,
|
|
3099
|
+
tier: credential.tier,
|
|
3100
|
+
source: credential.source,
|
|
3101
|
+
deliberate: credential.deliberate,
|
|
3102
|
+
diskCandidates: credential.diskCandidates,
|
|
3103
|
+
warning: credential.warning,
|
|
3104
|
+
...credential.pointerVaultKey !== undefined ? { pointerVaultKey: credential.pointerVaultKey } : {}
|
|
3105
|
+
});
|
|
3106
|
+
}
|
|
3107
|
+
function firstEnvValue2(env, keys) {
|
|
3108
|
+
for (const key of keys) {
|
|
3109
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
3110
|
+
continue;
|
|
3111
|
+
const value = env[key]?.trim();
|
|
3112
|
+
if (value)
|
|
3113
|
+
return { key, value };
|
|
3114
|
+
}
|
|
3115
|
+
return null;
|
|
3116
|
+
}
|
|
3117
|
+
var AMBIENT_ENVIRONMENT2 = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
3118
|
+
function isAmbientEnvironment2(env) {
|
|
3119
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT2] === true;
|
|
3120
|
+
}
|
|
3121
|
+
function defaultKeychainRunner2(argv) {
|
|
3122
|
+
const result = spawnSync3(KEYCHAIN_SECURITY_BIN2, [...argv], {
|
|
3123
|
+
encoding: "utf8",
|
|
3124
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3125
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS2
|
|
3126
|
+
});
|
|
3127
|
+
return {
|
|
3128
|
+
status: result.status,
|
|
3129
|
+
stdout: result.stdout ?? "",
|
|
3130
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
3131
|
+
};
|
|
3132
|
+
}
|
|
3133
|
+
function keychainTierEnabled2(env, options) {
|
|
3134
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
3135
|
+
return false;
|
|
3136
|
+
if (options.enabled !== undefined)
|
|
3137
|
+
return options.enabled;
|
|
3138
|
+
return options.run !== undefined || isAmbientEnvironment2(env);
|
|
3139
|
+
}
|
|
3140
|
+
function keychainAccount2(env, options) {
|
|
3141
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY2]?.trim();
|
|
3142
|
+
if (station)
|
|
3143
|
+
return station;
|
|
3144
|
+
const host = (options.hostname ?? osHostname2)().split(".")[0]?.trim() ?? "";
|
|
3145
|
+
if (host)
|
|
3146
|
+
return host;
|
|
3147
|
+
const user = env.USER?.trim();
|
|
3148
|
+
return user || null;
|
|
3149
|
+
}
|
|
3150
|
+
function keychainFailureHint2(text) {
|
|
3151
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
3152
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
3153
|
+
return clean ? `: ${clean}` : "";
|
|
3154
|
+
}
|
|
3155
|
+
function readKeychainItem2(name, env, kind, options) {
|
|
3156
|
+
if (!SAFE_APP_SLUG2.test(name) || !keychainTierEnabled2(env, options))
|
|
3157
|
+
return null;
|
|
3158
|
+
const account = keychainAccount2(env, options);
|
|
3159
|
+
if (!account)
|
|
3160
|
+
return null;
|
|
3161
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX2}.${name}.${kind}`;
|
|
3162
|
+
const source = `keychain:${service}@${account}`;
|
|
3163
|
+
const run = options.run ?? defaultKeychainRunner2;
|
|
3164
|
+
let result;
|
|
3165
|
+
try {
|
|
3166
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
3167
|
+
} catch (error) {
|
|
3168
|
+
const reason = keychainFailureHint2(error instanceof Error ? error.message : String(error));
|
|
3169
|
+
throw new CredentialResolutionError2(name, `The Keychain lookup for ${source} could not run${reason}. A Keychain failure is never resolved ` + `around: fix the keychain, or delete the item to fall through to the credential on disk.`, [source]);
|
|
3170
|
+
}
|
|
3171
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS2)
|
|
3172
|
+
return null;
|
|
3173
|
+
if (result.status !== 0) {
|
|
3174
|
+
throw new CredentialResolutionError2(name, `The Keychain lookup for ${source} failed (security exited ` + `${result.status ?? "without a status"}${keychainFailureHint2(result.stderr)}). A Keychain item that ` + `exists but cannot be read is never resolved around: unlock the keychain, run from a session that ` + `may use it, or delete the item to fall through to the credential on disk.`, [source]);
|
|
3175
|
+
}
|
|
3176
|
+
const value = result.stdout.trim();
|
|
3177
|
+
if (!value) {
|
|
3178
|
+
throw new CredentialResolutionError2(name, `${source} exists but holds an empty value; a declared item never falls through to another ` + `identity. Store a value in it or delete the item.`, [source]);
|
|
3179
|
+
}
|
|
3180
|
+
return { value, source };
|
|
3181
|
+
}
|
|
3182
|
+
function keychainConfigValue2(name, env, options = {}) {
|
|
3183
|
+
return readKeychainItem2(name, env, "api-url", options);
|
|
3184
|
+
}
|
|
3185
|
+
function snapshotClientEnvironment2(name, env) {
|
|
3186
|
+
const keys = clientTransportEnvKeys2(name);
|
|
3187
|
+
const ambient = isAmbientEnvironment2(env);
|
|
3188
|
+
const snapshot = Object.create(null);
|
|
3189
|
+
for (const key of [
|
|
3190
|
+
...keys.apiUrlKeys,
|
|
3191
|
+
...keys.apiKeyKeys,
|
|
3192
|
+
credentialOverrideEnvKey2(name),
|
|
3193
|
+
credentialPointerEnvKey2(name),
|
|
3194
|
+
CREDENTIAL_PROFILE_ENV_KEY2,
|
|
3195
|
+
"HOME",
|
|
3196
|
+
HASNA_HOME_ENV_KEY2,
|
|
3197
|
+
HASNA_CONFIG_HOME_ENV_KEY2,
|
|
3198
|
+
KEYCHAIN_STATION_ENV_KEY2,
|
|
3199
|
+
"USER"
|
|
3200
|
+
]) {
|
|
3201
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
3202
|
+
if (!descriptor)
|
|
3203
|
+
continue;
|
|
3204
|
+
if (!("value" in descriptor)) {
|
|
3205
|
+
throw new CredentialResolutionError2(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
3206
|
+
}
|
|
3207
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
3208
|
+
throw new CredentialResolutionError2(name, `${key} must be a string data property.`, [key]);
|
|
3209
|
+
}
|
|
3210
|
+
snapshot[key] = descriptor.value;
|
|
3211
|
+
}
|
|
3212
|
+
if (ambient) {
|
|
3213
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT2, {
|
|
3214
|
+
value: true,
|
|
3215
|
+
enumerable: false,
|
|
3216
|
+
writable: false,
|
|
3217
|
+
configurable: false
|
|
3218
|
+
});
|
|
3219
|
+
}
|
|
3220
|
+
return Object.freeze(snapshot);
|
|
3221
|
+
}
|
|
3222
|
+
function resolveCredential2(name, env, options = {}) {
|
|
3223
|
+
env = snapshotClientEnvironment2(name, env);
|
|
3224
|
+
const { apiKeyKeys } = clientTransportEnvKeys2(name);
|
|
3225
|
+
const diskPaths = credentialDiskSources2(name, env);
|
|
3226
|
+
if (options.apiKey !== undefined) {
|
|
3227
|
+
const explicitKey = options.apiKey.trim();
|
|
3228
|
+
if (!explicitKey) {
|
|
3229
|
+
throw new CredentialResolutionError2(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
3230
|
+
}
|
|
3231
|
+
assertUsableCredential2(name, "the explicit apiKey argument", explicitKey);
|
|
3232
|
+
return sealCredential2({
|
|
3233
|
+
apiKey: explicitKey,
|
|
3234
|
+
tier: "argument",
|
|
3235
|
+
source: "explicit apiKey argument",
|
|
3236
|
+
deliberate: true,
|
|
3237
|
+
diskCandidates: diskPaths,
|
|
3238
|
+
warning: null
|
|
3239
|
+
});
|
|
3240
|
+
}
|
|
3241
|
+
const overrideKeyName = credentialOverrideEnvKey2(name);
|
|
3242
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
3243
|
+
if (overrideRaw !== undefined) {
|
|
3244
|
+
const override = overrideRaw.trim();
|
|
3245
|
+
if (!override) {
|
|
3246
|
+
throw new CredentialResolutionError2(name, `${overrideKeyName} is set but empty. It is a deliberate override, so it is not resolved around: ` + `either give it a real key or unset it to fall back to the credential on disk.`, [overrideKeyName]);
|
|
3247
|
+
}
|
|
3248
|
+
assertUsableCredential2(name, overrideKeyName, override);
|
|
3249
|
+
return sealCredential2({
|
|
3250
|
+
apiKey: override,
|
|
3251
|
+
tier: "override",
|
|
3252
|
+
source: overrideKeyName,
|
|
3253
|
+
deliberate: true,
|
|
3254
|
+
diskCandidates: diskPaths,
|
|
3255
|
+
warning: null
|
|
3256
|
+
});
|
|
3257
|
+
}
|
|
3258
|
+
const pointerKeyName = credentialPointerEnvKey2(name);
|
|
3259
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
3260
|
+
if (pointerRaw !== undefined) {
|
|
3261
|
+
const pointer = pointerRaw.trim();
|
|
3262
|
+
if (!pointer) {
|
|
3263
|
+
throw new CredentialResolutionError2(name, `${pointerKeyName} is set but empty. It is a deliberate vault pointer, so it is not resolved around: ` + `either give it a vault item key or unset it to fall back to the credential on disk.`, [pointerKeyName]);
|
|
3264
|
+
}
|
|
3265
|
+
if (!VAULT_POINTER_SHAPE2.test(pointer)) {
|
|
3266
|
+
throw new CredentialResolutionError2(name, `${pointerKeyName} must name a vault ITEM KEY (a path-shaped reference like ` + `'namespace/app/live/api_key'), not a credential value. A pointer that carries a literal is refused.`, [pointerKeyName]);
|
|
3267
|
+
}
|
|
3268
|
+
return sealCredential2({
|
|
3269
|
+
apiKey: "",
|
|
3270
|
+
pointerVaultKey: pointer,
|
|
3271
|
+
tier: "pointer",
|
|
3272
|
+
source: pointerKeyName,
|
|
3273
|
+
deliberate: true,
|
|
3274
|
+
diskCandidates: diskPaths,
|
|
3275
|
+
warning: null
|
|
3276
|
+
});
|
|
3277
|
+
}
|
|
3278
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
3279
|
+
throw new CredentialResolutionError2(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
3280
|
+
}
|
|
3281
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY2) ? env[CREDENTIAL_PROFILE_ENV_KEY2] : undefined;
|
|
3282
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
3283
|
+
throw new CredentialResolutionError2(name, `${CREDENTIAL_PROFILE_ENV_KEY2} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY2]);
|
|
3284
|
+
}
|
|
3285
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
3286
|
+
if (profile) {
|
|
3287
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY2;
|
|
3288
|
+
if (!SAFE_PROFILE2.test(profile)) {
|
|
3289
|
+
throw new CredentialResolutionError2(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
3290
|
+
}
|
|
3291
|
+
const paths = profileDiskSources2(name, env, profile);
|
|
3292
|
+
for (const path of paths) {
|
|
3293
|
+
const value = readCredentialFile2(path, apiKeyKeys);
|
|
3294
|
+
if (value) {
|
|
3295
|
+
assertUsableCredential2(name, path, value);
|
|
3296
|
+
return sealCredential2({
|
|
3297
|
+
apiKey: value,
|
|
3298
|
+
tier: "profile",
|
|
3299
|
+
source: path,
|
|
3300
|
+
deliberate: true,
|
|
3301
|
+
diskCandidates: paths,
|
|
3302
|
+
warning: null
|
|
3303
|
+
});
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
throw new CredentialResolutionError2(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY2}.`, paths);
|
|
3307
|
+
}
|
|
3308
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
3309
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
3310
|
+
if (blankEnv) {
|
|
3311
|
+
throw new CredentialResolutionError2(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
3312
|
+
}
|
|
3313
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
3314
|
+
throw new CredentialResolutionError2(name, `${definedEnvEntries.map((entry) => entry.key).join(" and ")} disagree; credential aliases must be identical or only one may be set.`, definedEnvEntries.map((entry) => entry.key));
|
|
3315
|
+
}
|
|
3316
|
+
const envHit = firstEnvValue2(env, apiKeyKeys);
|
|
3317
|
+
const keychainHit = readKeychainItem2(name, env, "api-key", options.keychain ?? {});
|
|
3318
|
+
if (keychainHit) {
|
|
3319
|
+
assertUsableCredential2(name, keychainHit.source, keychainHit.value);
|
|
3320
|
+
const warning = envHit && envHit.value !== keychainHit.value ? `Credential sources disagree for '${name}': ${keychainHit.source} and ${envHit.key} hold ` + `different keys. ${keychainHit.source} wins, because the Keychain is re-read on every call while ` + `an environment variable is a snapshot. Reconcile them \u2014 a rotation that updated only one leaves ` + `the other to fail 401 wherever it is loaded first.` : null;
|
|
3321
|
+
return sealCredential2({
|
|
3322
|
+
apiKey: keychainHit.value,
|
|
3323
|
+
tier: "keychain",
|
|
3324
|
+
source: keychainHit.source,
|
|
3325
|
+
deliberate: false,
|
|
3326
|
+
diskCandidates: diskPaths,
|
|
3327
|
+
warning
|
|
3328
|
+
});
|
|
3329
|
+
}
|
|
3330
|
+
const diskSourceList = credentialDiskSourceList2(name, env, null);
|
|
3331
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile2(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
3332
|
+
if (diskHits.length > 0) {
|
|
3333
|
+
const winner = diskHits[0];
|
|
3334
|
+
assertUsableCredential2(name, winner.src.path, winner.value);
|
|
3335
|
+
const divergentSources = [
|
|
3336
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
|
|
3337
|
+
...envHit && envHit.value !== winner.value ? [envHit.key] : []
|
|
3338
|
+
];
|
|
3339
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.src.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.src.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
|
|
3340
|
+
return sealCredential2({
|
|
3341
|
+
apiKey: winner.value,
|
|
3342
|
+
tier: winner.src.tier,
|
|
3343
|
+
source: winner.src.path,
|
|
3344
|
+
deliberate: false,
|
|
3345
|
+
diskCandidates: diskPaths,
|
|
3346
|
+
warning
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
if (envHit) {
|
|
3350
|
+
assertUsableCredential2(name, envHit.key, envHit.value);
|
|
3351
|
+
return sealCredential2({
|
|
3352
|
+
apiKey: envHit.value,
|
|
3353
|
+
tier: "env",
|
|
3354
|
+
source: envHit.key,
|
|
3355
|
+
deliberate: false,
|
|
3356
|
+
diskCandidates: diskPaths,
|
|
3357
|
+
warning: null
|
|
3358
|
+
});
|
|
3359
|
+
}
|
|
3360
|
+
return null;
|
|
3361
|
+
}
|
|
3362
|
+
var SECRETS_PACKAGE_SPECIFIER2 = "@hasna/" + "secrets";
|
|
3363
|
+
var requireSecretsSdk2 = createRequire2(import.meta.url);
|
|
3364
|
+
async function completePointerCredential(name, pointerResolution, env = process.env) {
|
|
3365
|
+
const vaultKey = pointerResolution.pointerVaultKey;
|
|
3366
|
+
const pointerEnvKey = pointerResolution.source;
|
|
3367
|
+
if (!vaultKey) {
|
|
3368
|
+
throw new CredentialResolutionError2(name, `Pointer resolution from ${pointerEnvKey} carries no vault item key; this is a defect in the resolver.`, [pointerEnvKey]);
|
|
3369
|
+
}
|
|
3370
|
+
let secretsSdk;
|
|
3371
|
+
try {
|
|
3372
|
+
secretsSdk = requireSecretsSdk2(SECRETS_PACKAGE_SPECIFIER2);
|
|
3373
|
+
} catch {
|
|
3374
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} names vault item '${vaultKey}', but the secrets SDK (@hasna/secrets) is not installed ` + `in this process. A vault pointer is TERMINAL: install @hasna/secrets to resolve it, or unset ${pointerEnvKey}.`, [pointerEnvKey]);
|
|
3375
|
+
}
|
|
3376
|
+
let client;
|
|
3377
|
+
try {
|
|
3378
|
+
client = secretsSdk.createSecretsClientFromEnv(env);
|
|
3379
|
+
} catch {
|
|
3380
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} names vault item '${vaultKey}', but the secrets client could not be configured from this ` + `environment (the secrets service URL and key env are missing or invalid). A vault pointer is TERMINAL and ` + `never falls through to a literal or disk credential.`, [pointerEnvKey]);
|
|
3381
|
+
}
|
|
3382
|
+
let secret;
|
|
3383
|
+
try {
|
|
3384
|
+
secret = await client.getSecret({ key: vaultKey });
|
|
3385
|
+
} catch {
|
|
3386
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} names vault item '${vaultKey}', but the vault could not be reached or the item is ` + `unavailable. A vault pointer is TERMINAL and never falls through to a literal or disk credential.`, [pointerEnvKey]);
|
|
3387
|
+
}
|
|
3388
|
+
const value = secret.value;
|
|
3389
|
+
if (!value) {
|
|
3390
|
+
throw new CredentialResolutionError2(name, `${pointerEnvKey} resolved vault item '${vaultKey}', but it holds no value. A vault pointer is TERMINAL.`, [pointerEnvKey]);
|
|
3391
|
+
}
|
|
3392
|
+
assertUsableCredential2(name, `${pointerEnvKey} -> vault:${vaultKey}`, value);
|
|
3393
|
+
return sealCredential2({
|
|
3394
|
+
apiKey: value,
|
|
3395
|
+
tier: "pointer",
|
|
3396
|
+
source: `${pointerEnvKey} -> vault:${vaultKey}`,
|
|
3397
|
+
deliberate: true,
|
|
3398
|
+
diskCandidates: pointerResolution.diskCandidates,
|
|
3399
|
+
warning: null
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN2 = "https://api.hasna.com";
|
|
3403
|
+
var DEFAULT_AUTHORITY_SOURCE2 = "default";
|
|
3404
|
+
function defaultFleetGatewayBaseUrl2(name) {
|
|
3405
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN2}/${validateAppSlug2(name)}`;
|
|
3406
|
+
}
|
|
3407
|
+
var ASCII_CONTROL_PATTERN2 = /[\u0000-\u001f\u007f]/;
|
|
3408
|
+
var DNS_LABEL_PATTERN2 = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
3409
|
+
function isValidDnsDomain2(value) {
|
|
3410
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN2.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
3411
|
+
return false;
|
|
2388
3412
|
}
|
|
2389
|
-
return
|
|
3413
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN2.test(label));
|
|
2390
3414
|
}
|
|
2391
|
-
function
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
return key;
|
|
3415
|
+
function validateAppSlug2(name) {
|
|
3416
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN2.test(name)) {
|
|
3417
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
2395
3418
|
}
|
|
2396
|
-
return
|
|
3419
|
+
return name;
|
|
2397
3420
|
}
|
|
2398
|
-
function
|
|
3421
|
+
function rawAuthority2(value) {
|
|
2399
3422
|
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
2400
3423
|
if (!match)
|
|
2401
3424
|
throw new Error("API URL must be absolute.");
|
|
@@ -2406,7 +3429,7 @@ function rawAuthority(value) {
|
|
|
2406
3429
|
throw new Error("API URL must include a hostname.");
|
|
2407
3430
|
return authority;
|
|
2408
3431
|
}
|
|
2409
|
-
function
|
|
3432
|
+
function assertCanonicalPort2(port) {
|
|
2410
3433
|
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
2411
3434
|
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2412
3435
|
}
|
|
@@ -2415,7 +3438,7 @@ function assertCanonicalPort(port) {
|
|
|
2415
3438
|
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
2416
3439
|
}
|
|
2417
3440
|
}
|
|
2418
|
-
function
|
|
3441
|
+
function canonicalAuthorityHostname2(authority) {
|
|
2419
3442
|
let rawHostname;
|
|
2420
3443
|
if (authority.startsWith("[")) {
|
|
2421
3444
|
const closingBracket = authority.indexOf("]");
|
|
@@ -2428,9 +3451,9 @@ function canonicalAuthorityHostname(authority) {
|
|
|
2428
3451
|
if (!portSuffix.startsWith(":")) {
|
|
2429
3452
|
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
2430
3453
|
}
|
|
2431
|
-
|
|
3454
|
+
assertCanonicalPort2(portSuffix.slice(1));
|
|
2432
3455
|
}
|
|
2433
|
-
if (
|
|
3456
|
+
if (isIP2(rawHostname.slice(1, -1)) !== 6) {
|
|
2434
3457
|
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
2435
3458
|
}
|
|
2436
3459
|
} else {
|
|
@@ -2441,33 +3464,33 @@ function canonicalAuthorityHostname(authority) {
|
|
|
2441
3464
|
}
|
|
2442
3465
|
if (lastColon !== -1) {
|
|
2443
3466
|
const port = authority.slice(lastColon + 1);
|
|
2444
|
-
|
|
3467
|
+
assertCanonicalPort2(port);
|
|
2445
3468
|
rawHostname = authority.slice(0, lastColon);
|
|
2446
3469
|
} else {
|
|
2447
3470
|
rawHostname = authority;
|
|
2448
3471
|
}
|
|
2449
|
-
const ipVersion =
|
|
3472
|
+
const ipVersion = isIP2(rawHostname);
|
|
2450
3473
|
const numericAddressParts = rawHostname.split(".");
|
|
2451
3474
|
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
2452
|
-
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !
|
|
3475
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain2(rawHostname.toLowerCase())) {
|
|
2453
3476
|
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
2454
3477
|
}
|
|
2455
3478
|
}
|
|
2456
3479
|
return rawHostname.toLowerCase();
|
|
2457
3480
|
}
|
|
2458
|
-
function
|
|
3481
|
+
function isDeliberateLoopbackHttpAuthority2(authority) {
|
|
2459
3482
|
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
2460
3483
|
}
|
|
2461
|
-
function
|
|
2462
|
-
if (
|
|
3484
|
+
function toV1BaseUrl2(apiUrl) {
|
|
3485
|
+
if (ASCII_CONTROL_PATTERN2.test(apiUrl)) {
|
|
2463
3486
|
throw new Error("API URL must not contain ASCII control characters.");
|
|
2464
3487
|
}
|
|
2465
3488
|
const input = apiUrl.trim();
|
|
2466
|
-
const authority =
|
|
3489
|
+
const authority = rawAuthority2(input);
|
|
2467
3490
|
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
2468
3491
|
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
2469
3492
|
}
|
|
2470
|
-
const canonicalHostname =
|
|
3493
|
+
const canonicalHostname = canonicalAuthorityHostname2(authority);
|
|
2471
3494
|
const url = new URL(input);
|
|
2472
3495
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2473
3496
|
throw new Error("API URL must use http or https.");
|
|
@@ -2484,7 +3507,7 @@ function toV1BaseUrl(apiUrl) {
|
|
|
2484
3507
|
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
2485
3508
|
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
2486
3509
|
}
|
|
2487
|
-
if (url.protocol === "http:" && !
|
|
3510
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority2(authority)) {
|
|
2488
3511
|
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
2489
3512
|
}
|
|
2490
3513
|
if (url.search || url.hash) {
|
|
@@ -2496,106 +3519,104 @@ function toV1BaseUrl(apiUrl) {
|
|
|
2496
3519
|
url.pathname = `${path}/v1`;
|
|
2497
3520
|
return url.toString().replace(/\/+$/, "");
|
|
2498
3521
|
}
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
3522
|
+
class ClientTransportConfigurationError2 extends Error {
|
|
3523
|
+
appName;
|
|
3524
|
+
sources;
|
|
3525
|
+
constructor(appName, message, sources = []) {
|
|
3526
|
+
super(message);
|
|
3527
|
+
this.name = "ClientTransportConfigurationError";
|
|
3528
|
+
this.appName = appName;
|
|
3529
|
+
this.sources = Object.freeze([...sources]);
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
function resolveClientTransportSnapshot2(name, env = process.env, options = {}) {
|
|
3533
|
+
env = snapshotClientEnvironment2(name, env);
|
|
3534
|
+
const keys = clientTransportEnvKeys2(name);
|
|
3535
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
3536
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
3537
|
+
if (blankUrl) {
|
|
3538
|
+
throw new ClientTransportConfigurationError2(name, `${blankUrl.key} is set but blank; public clients require an explicit HTTPS API URL and never select local storage.`, [blankUrl.key]);
|
|
3539
|
+
}
|
|
3540
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN2.test(entry.raw));
|
|
3541
|
+
if (controlledUrl) {
|
|
3542
|
+
throw new ClientTransportConfigurationError2(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
3543
|
+
}
|
|
3544
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
3545
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map((entry) => entry.value)).size > 1) {
|
|
3546
|
+
throw new ClientTransportConfigurationError2(name, `${usableUrlEntries.map((entry) => entry.key).join(" and ")} disagree; client authority aliases must be identical or only one may be set.`, usableUrlEntries.map((entry) => entry.key));
|
|
3547
|
+
}
|
|
3548
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
3549
|
+
const keychainUrlHit = keychainConfigValue2(name, env, options.credentials?.keychain);
|
|
3550
|
+
const diskConfigUrlHit = appConfigDiskValue2(name, env, keys.apiUrlKeys);
|
|
3551
|
+
if (diskConfigUrlHit?.unusable) {
|
|
3552
|
+
throw new ClientTransportConfigurationError2(name, `${diskConfigUrlHit.key} in ${diskConfigUrlHit.path} is declared but blank or malformed; public clients require a valid HTTPS service authority.`, [diskConfigUrlHit.path]);
|
|
3553
|
+
}
|
|
3554
|
+
const urlCandidates = [
|
|
3555
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
3556
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
3557
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
3558
|
+
];
|
|
3559
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
3560
|
+
const divergentUrls = urlCandidates.filter((candidate) => candidate.value !== configuredUrl?.value);
|
|
3561
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
3562
|
+
throw new ClientTransportConfigurationError2(name, `${configuredUrl.key} and ${divergentUrls.map((candidate) => candidate.key).join(" and ")} select different service authorities; refusing to send a credential written for one authority to the other.`, urlCandidates.map((candidate) => candidate.key));
|
|
2536
3563
|
}
|
|
2537
|
-
|
|
2538
|
-
|
|
3564
|
+
const warnings = [];
|
|
3565
|
+
if (configuredUrl && !envUrlHit) {
|
|
3566
|
+
warnings.push(`No ${keys.apiUrlKeys[0]} in the environment; the server URL in ${configuredUrl.key} was used, so this client connects to the server. ` + `Keep that entry aligned with the intended service authority.`);
|
|
2539
3567
|
}
|
|
2540
|
-
const credential =
|
|
3568
|
+
const credential = resolveCredential2(name, env, options.credentials);
|
|
2541
3569
|
if (!credential) {
|
|
2542
|
-
const diskHint =
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
transportSource: urlHit.key,
|
|
2547
|
-
baseUrl: null,
|
|
2548
|
-
apiUrlSource: urlHit.key,
|
|
2549
|
-
apiKeyPresent: false,
|
|
2550
|
-
apiKeySource: null,
|
|
2551
|
-
apiKeyTier: null,
|
|
2552
|
-
misconfigured: true,
|
|
2553
|
-
warning: warnings.join(" ")
|
|
2554
|
-
};
|
|
3570
|
+
const diskHint = credentialDiskSourcesForMessage2(name, env);
|
|
3571
|
+
const lead = configuredUrl ? `${configuredUrl.key} selects the HTTP server for '${name}', but no API key could be resolved` : `${keys.apiUrlKeys[0]} is not set and no API key could be resolved for '${name}'; a credential is required before the default fleet gateway authority applies`;
|
|
3572
|
+
warnings.push(`${lead}; refusing to create an unauthenticated client \u2014 public clients never fall back to SQLite or another local store. ` + `Looked in the Keychain (macOS only), then for a credential file at ${diskHint}, then for ${keys.apiKeyKeys[0]} in the environment.`);
|
|
3573
|
+
throw new ClientTransportConfigurationError2(name, warnings.join(" "), [configuredUrl?.key ?? keys.apiUrlKeys[0]]);
|
|
2555
3574
|
}
|
|
2556
3575
|
if (credential.warning)
|
|
2557
3576
|
warnings.push(credential.warning);
|
|
3577
|
+
let urlHit;
|
|
3578
|
+
if (configuredUrl) {
|
|
3579
|
+
urlHit = configuredUrl;
|
|
3580
|
+
} else {
|
|
3581
|
+
try {
|
|
3582
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE2, value: defaultFleetGatewayBaseUrl2(name) };
|
|
3583
|
+
} catch (error) {
|
|
3584
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3585
|
+
throw new ClientTransportConfigurationError2(name, `No ${keys.apiUrlKeys[0]} is configured and the default fleet gateway authority cannot be composed for '${name}': ${message}`, [keys.apiUrlKeys[0]]);
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
2558
3588
|
const apiUrlSource = urlHit.key;
|
|
2559
3589
|
let baseUrl;
|
|
2560
3590
|
try {
|
|
2561
|
-
baseUrl =
|
|
3591
|
+
baseUrl = toV1BaseUrl2(urlHit.value);
|
|
2562
3592
|
} catch (error) {
|
|
2563
3593
|
const message = error instanceof Error ? error.message : String(error);
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
3594
|
+
throw new ClientTransportConfigurationError2(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
3595
|
+
}
|
|
3596
|
+
return {
|
|
3597
|
+
resolution: {
|
|
3598
|
+
transport: "http",
|
|
2567
3599
|
transportSource: urlHit.key,
|
|
2568
|
-
baseUrl
|
|
2569
|
-
apiUrlSource
|
|
3600
|
+
baseUrl,
|
|
3601
|
+
apiUrlSource,
|
|
2570
3602
|
apiKeyPresent: true,
|
|
2571
3603
|
apiKeySource: credential.source,
|
|
2572
3604
|
apiKeyTier: credential.tier,
|
|
2573
|
-
misconfigured:
|
|
2574
|
-
warning: warnings.join(" ")
|
|
2575
|
-
}
|
|
2576
|
-
|
|
2577
|
-
return {
|
|
2578
|
-
transport: "http",
|
|
2579
|
-
transportSource: urlHit.key,
|
|
2580
|
-
baseUrl,
|
|
2581
|
-
apiUrlSource,
|
|
2582
|
-
apiKeyPresent: true,
|
|
2583
|
-
apiKeySource: credential.source,
|
|
2584
|
-
apiKeyTier: credential.tier,
|
|
2585
|
-
misconfigured: false,
|
|
2586
|
-
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
3605
|
+
misconfigured: false,
|
|
3606
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
3607
|
+
},
|
|
3608
|
+
credential
|
|
2587
3609
|
};
|
|
2588
3610
|
}
|
|
2589
|
-
function
|
|
2590
|
-
const paths =
|
|
2591
|
-
return paths.length > 0 ? paths.join(" or ") : "<no HOME set in this environment, so no credential file was consulted>";
|
|
3611
|
+
function credentialDiskSourcesForMessage2(name, env) {
|
|
3612
|
+
const paths = credentialDiskSources2(name, env);
|
|
3613
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
2592
3614
|
}
|
|
2593
3615
|
|
|
2594
3616
|
class HasnaHttpError extends Error {
|
|
2595
3617
|
status;
|
|
2596
3618
|
method;
|
|
2597
3619
|
path;
|
|
2598
|
-
body;
|
|
2599
3620
|
credentialSource;
|
|
2600
3621
|
credentialTier;
|
|
2601
3622
|
constructor(method, path, status, body, credential) {
|
|
@@ -2605,7 +3626,12 @@ class HasnaHttpError extends Error {
|
|
|
2605
3626
|
this.status = status;
|
|
2606
3627
|
this.method = method;
|
|
2607
3628
|
this.path = path;
|
|
2608
|
-
this
|
|
3629
|
+
Object.defineProperty(this, "body", {
|
|
3630
|
+
value: body,
|
|
3631
|
+
enumerable: status !== 401 && status !== 403,
|
|
3632
|
+
writable: false,
|
|
3633
|
+
configurable: false
|
|
3634
|
+
});
|
|
2609
3635
|
this.credentialSource = credential?.source ?? null;
|
|
2610
3636
|
this.credentialTier = credential?.tier ?? null;
|
|
2611
3637
|
}
|
|
@@ -2616,22 +3642,32 @@ function currentCredential(name, apiKey) {
|
|
|
2616
3642
|
}
|
|
2617
3643
|
return explicitCredential(name, apiKey);
|
|
2618
3644
|
}
|
|
3645
|
+
async function resolveRequestCredential(name, apiKey, env = process.env) {
|
|
3646
|
+
const resolved = currentCredential(name, apiKey);
|
|
3647
|
+
if (resolved.tier === "pointer") {
|
|
3648
|
+
return completePointerCredential(name, resolved, env);
|
|
3649
|
+
}
|
|
3650
|
+
return resolved;
|
|
3651
|
+
}
|
|
2619
3652
|
function authFailureGuidance(credential) {
|
|
2620
3653
|
const origin = `The API key for this request came from ${credential.source}`;
|
|
2621
3654
|
if (credential.deliberate) {
|
|
2622
3655
|
const remedy = credential.source === CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE ? `Fix that provider so it returns the current key, or replace it with resolveCredential() ` + `so diagnostics can name the original source.` : `Rotate that key, or unset the override to use the credential on disk.`;
|
|
2623
3656
|
return `${origin} \u2014 a credential you selected deliberately. It was NOT substituted with any other key: ` + `falling back here would authenticate as a different principal than the one you named, which is ` + `exactly the failure an override exists to prevent. ${remedy}`;
|
|
2624
3657
|
}
|
|
2625
|
-
if (credential.
|
|
3658
|
+
if (credential.tier === "env") {
|
|
2626
3659
|
const target = credential.diskCandidates[0];
|
|
2627
|
-
const remedy = target ? `
|
|
2628
|
-
return `${origin}, a variable in this process's environment
|
|
3660
|
+
const remedy = target ? `Store the CURRENT key in the Keychain or write it to ${target} \u2014 both are re-read on every call, so ` + `rotations take effect immediately and in every shell. Do not simply unset ${credential.source}: ` + `nothing was found in the Keychain or on disk, so that would leave this client with no credential at all.` : `This environment has no HOME or HASNA_HOME, so no credential file could be consulted; the disk tier is ` + `unavailable here and there is nothing to fall back to. Set HOME, or supply the key explicitly.`;
|
|
3661
|
+
return `${origin}, a variable in this process's environment. If a wrapper injected it for this one process, the ` + `wrapper re-reads its store on every invocation and the stored key itself is being rejected \u2014 rotate it. ` + `If this SHELL exported it, the export is a snapshot taken when the shell started: a STALE SHELL that ` + `exported the key before it was rotated keeps sending the old one until it exits. ${remedy}`;
|
|
3662
|
+
}
|
|
3663
|
+
if (credential.tier === "keychain") {
|
|
3664
|
+
return `${origin}, which was re-read from the Keychain on this very call \u2014 so a stale shell is NOT the cause ` + `here. The stored item is genuinely being rejected: update it with the current key, or re-run the fleet ` + `key distribution so this machine gets the current key.`;
|
|
2629
3665
|
}
|
|
2630
3666
|
return `${origin}, which was re-read from disk on this very call \u2014 so a stale shell is NOT the cause here. ` + `The stored credential is genuinely being rejected: rotate it, or re-run the fleet key distribution ` + `so this machine gets the current key.`;
|
|
2631
3667
|
}
|
|
2632
3668
|
var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
2633
|
-
var
|
|
2634
|
-
var
|
|
3669
|
+
var IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
3670
|
+
var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
2635
3671
|
"host",
|
|
2636
3672
|
":authority",
|
|
2637
3673
|
"forwarded",
|
|
@@ -2641,7 +3677,7 @@ var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
|
2641
3677
|
function assertNoAuthorityOverrideHeaders(headers, source) {
|
|
2642
3678
|
if (!headers)
|
|
2643
3679
|
return;
|
|
2644
|
-
const forbidden = Object.keys(headers).find((name) =>
|
|
3680
|
+
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS2.has(name.trim().toLowerCase()));
|
|
2645
3681
|
if (forbidden) {
|
|
2646
3682
|
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
2647
3683
|
}
|
|
@@ -2668,9 +3704,9 @@ function appendQuery(path, query) {
|
|
|
2668
3704
|
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
2669
3705
|
}
|
|
2670
3706
|
var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
2671
|
-
function
|
|
3707
|
+
function createHasnaHttpTransportInternal(options, requestBindingProvider) {
|
|
2672
3708
|
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
2673
|
-
const base =
|
|
3709
|
+
const base = toV1BaseUrl2(options.baseUrl);
|
|
2674
3710
|
const timeoutMs = options.timeoutMs ?? 30000;
|
|
2675
3711
|
const sleep = options.sleepImpl ?? defaultSleep;
|
|
2676
3712
|
const defaultRetry = options.retry;
|
|
@@ -2730,13 +3766,20 @@ function createHasnaHttpTransport(options) {
|
|
|
2730
3766
|
if (opts.signal)
|
|
2731
3767
|
opts.signal.removeEventListener("abort", onAbort);
|
|
2732
3768
|
}
|
|
2733
|
-
const
|
|
3769
|
+
const authenticationFailure = response.status === 401 || response.status === 403;
|
|
2734
3770
|
let parsed = undefined;
|
|
2735
|
-
if (
|
|
3771
|
+
if (authenticationFailure) {
|
|
2736
3772
|
try {
|
|
2737
|
-
|
|
2738
|
-
} catch {
|
|
2739
|
-
|
|
3773
|
+
await response.body?.cancel();
|
|
3774
|
+
} catch {}
|
|
3775
|
+
} else {
|
|
3776
|
+
const text = await response.text();
|
|
3777
|
+
if (text.length > 0) {
|
|
3778
|
+
try {
|
|
3779
|
+
parsed = JSON.parse(text);
|
|
3780
|
+
} catch {
|
|
3781
|
+
parsed = text;
|
|
3782
|
+
}
|
|
2740
3783
|
}
|
|
2741
3784
|
}
|
|
2742
3785
|
if (!response.ok) {
|
|
@@ -2747,11 +3790,11 @@ function createHasnaHttpTransport(options) {
|
|
|
2747
3790
|
error: new HasnaHttpError(method, rel, response.status, parsed)
|
|
2748
3791
|
};
|
|
2749
3792
|
}
|
|
2750
|
-
if (
|
|
3793
|
+
if (authenticationFailure) {
|
|
2751
3794
|
return {
|
|
2752
3795
|
ok: false,
|
|
2753
3796
|
retryable: false,
|
|
2754
|
-
error: new HasnaHttpError(method, rel, response.status,
|
|
3797
|
+
error: new HasnaHttpError(method, rel, response.status, undefined, {
|
|
2755
3798
|
source: credential.source,
|
|
2756
3799
|
tier: credential.tier,
|
|
2757
3800
|
guidance: authFailureGuidance(credential)
|
|
@@ -2767,11 +3810,15 @@ function createHasnaHttpTransport(options) {
|
|
|
2767
3810
|
async function request(method, path, body, opts = {}) {
|
|
2768
3811
|
const upper = method.toUpperCase();
|
|
2769
3812
|
const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
2770
|
-
const url = `${base}${rel}`;
|
|
2771
3813
|
const retry = resolveRetry(opts.retry);
|
|
2772
|
-
const methodRetryable =
|
|
3814
|
+
const methodRetryable = IDEMPOTENT_METHODS2.has(upper) || Boolean(opts.idempotencyKey);
|
|
2773
3815
|
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
2774
|
-
const
|
|
3816
|
+
const binding = requestBindingProvider ? await requestBindingProvider() : {
|
|
3817
|
+
baseUrl: base,
|
|
3818
|
+
credential: await resolveRequestCredential(options.name, options.apiKey)
|
|
3819
|
+
};
|
|
3820
|
+
const url = `${binding.baseUrl}${rel}`;
|
|
3821
|
+
const credential = binding.credential;
|
|
2775
3822
|
let last = null;
|
|
2776
3823
|
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
2777
3824
|
const result = await once(upper, rel, url, body, opts, credential);
|
|
@@ -2799,49 +3846,44 @@ function createHasnaHttpTransport(options) {
|
|
|
2799
3846
|
}
|
|
2800
3847
|
function createClientTransport(name, env = process.env, overrides) {
|
|
2801
3848
|
const credentialOptions = overrides?.credentials;
|
|
2802
|
-
const
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
if (
|
|
2812
|
-
throw new
|
|
2813
|
-
}
|
|
2814
|
-
|
|
3849
|
+
const snapshotOptions = { ...credentialOptions ? { credentials: credentialOptions } : {} };
|
|
3850
|
+
const resolution = resolveClientTransportSnapshot2(name, env, snapshotOptions).resolution;
|
|
3851
|
+
const sameBinding = (left, right) => left.resolution.baseUrl === right.resolution.baseUrl && left.credential.apiKey === right.credential.apiKey && left.credential.pointerVaultKey === right.credential.pointerVaultKey && left.credential.source === right.credential.source && left.credential.tier === right.credential.tier;
|
|
3852
|
+
const unstableConfiguration = () => new ClientTransportConfigurationError2(name, "The configured service authority or credential changed while a request was being prepared; no authenticated request was sent.");
|
|
3853
|
+
const requestBindingProvider = async () => {
|
|
3854
|
+
const first = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
3855
|
+
const reviewed = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
3856
|
+
if (!sameBinding(first, reviewed))
|
|
3857
|
+
throw unstableConfiguration();
|
|
3858
|
+
if (reviewed.resolution.baseUrl !== resolution.baseUrl) {
|
|
3859
|
+
throw new ClientTransportConfigurationError2(name, "The configured service authority changed; rebuild the client before sending credentials.");
|
|
3860
|
+
}
|
|
3861
|
+
const credential = await resolveRequestCredential(name, () => reviewed.credential, env);
|
|
3862
|
+
const immediatelyBeforeDispatch = resolveClientTransportSnapshot2(name, env, snapshotOptions);
|
|
3863
|
+
if (!sameBinding(reviewed, immediatelyBeforeDispatch))
|
|
3864
|
+
throw unstableConfiguration();
|
|
3865
|
+
if (immediatelyBeforeDispatch.resolution.baseUrl !== resolution.baseUrl) {
|
|
3866
|
+
throw new ClientTransportConfigurationError2(name, "The configured service authority changed; rebuild the client before sending credentials.");
|
|
3867
|
+
}
|
|
3868
|
+
return { baseUrl: immediatelyBeforeDispatch.resolution.baseUrl, credential };
|
|
2815
3869
|
};
|
|
2816
3870
|
return {
|
|
2817
3871
|
transport: "http",
|
|
2818
|
-
client:
|
|
3872
|
+
client: createHasnaHttpTransportInternal({
|
|
2819
3873
|
name,
|
|
2820
3874
|
baseUrl: resolution.baseUrl,
|
|
2821
|
-
apiKey:
|
|
3875
|
+
apiKey: () => {
|
|
3876
|
+
throw new Error("The authenticated request binding provider was not invoked.");
|
|
3877
|
+
},
|
|
2822
3878
|
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
2823
3879
|
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
2824
3880
|
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
2825
3881
|
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
2826
3882
|
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
2827
|
-
}),
|
|
3883
|
+
}, requestBindingProvider),
|
|
2828
3884
|
resolution
|
|
2829
3885
|
};
|
|
2830
3886
|
}
|
|
2831
|
-
|
|
2832
|
-
// ../contracts/dist/client/storage.js
|
|
2833
|
-
var MAX_CREDENTIAL_FILE_BYTES2 = 64 * 1024;
|
|
2834
|
-
var INSPECT_CUSTOM2 = Symbol.for("nodejs.util.inspect.custom");
|
|
2835
|
-
var CREDENTIAL_SEAL2 = Symbol.for("hasna:contracts:sealedCredential");
|
|
2836
|
-
var DEPRECATION_REGISTRY2 = Symbol.for("hasna:contracts:credentialDeprecationNotices");
|
|
2837
|
-
var IDEMPOTENT_METHODS2 = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
2838
|
-
var AUTHORITY_OVERRIDE_HEADERS2 = new Set([
|
|
2839
|
-
"host",
|
|
2840
|
-
":authority",
|
|
2841
|
-
"forwarded",
|
|
2842
|
-
"x-forwarded-host",
|
|
2843
|
-
"x-original-host"
|
|
2844
|
-
]);
|
|
2845
3887
|
function resourcePath(resource) {
|
|
2846
3888
|
const trimmed = resource.replace(/^\/+|\/+$/g, "");
|
|
2847
3889
|
if (!trimmed)
|
|
@@ -2941,145 +3983,336 @@ function createHasnaStorageClient(name, transport) {
|
|
|
2941
3983
|
}
|
|
2942
3984
|
};
|
|
2943
3985
|
}
|
|
3986
|
+
function resolveStorageClient(name, env = process.env, overrides) {
|
|
3987
|
+
const wired = createClientTransport(name, env, overrides);
|
|
3988
|
+
return { transport: "http", client: createHasnaStorageClient(name, wired.client) };
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
// src/lib/local-opt-in.ts
|
|
3992
|
+
var RECORDINGS_LOCAL_OPT_IN_ENV_KEYS = [
|
|
3993
|
+
"HASNA_RECORDINGS_LOCAL",
|
|
3994
|
+
"RECORDINGS_LOCAL"
|
|
3995
|
+
];
|
|
3996
|
+
function isRecordingsLocalOptIn(env = process.env) {
|
|
3997
|
+
return RECORDINGS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
|
|
3998
|
+
}
|
|
3999
|
+
var RECORDINGS_CARVED_LEGACY_ENV_KEYS = [
|
|
4000
|
+
"RECORDINGS_API_URL",
|
|
4001
|
+
"RECORDINGS_API_KEY"
|
|
4002
|
+
];
|
|
4003
|
+
function recordingsAuthorityEnvKeys() {
|
|
4004
|
+
const keys = clientTransportEnvKeys("recordings");
|
|
4005
|
+
return [
|
|
4006
|
+
...keys.apiUrlKeys,
|
|
4007
|
+
...keys.apiKeyKeys,
|
|
4008
|
+
credentialOverrideEnvKey("recordings"),
|
|
4009
|
+
credentialPointerEnvKey("recordings"),
|
|
4010
|
+
CREDENTIAL_PROFILE_ENV_KEY
|
|
4011
|
+
].filter((key) => !RECORDINGS_CARVED_LEGACY_ENV_KEYS.includes(key));
|
|
4012
|
+
}
|
|
4013
|
+
function hasRecordingsEnvAuthorityIntent(env = process.env) {
|
|
4014
|
+
return recordingsAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
|
|
4015
|
+
}
|
|
4016
|
+
function selectsRecordingsLocalStore(env = process.env) {
|
|
4017
|
+
return !hasRecordingsEnvAuthorityIntent(env) && isRecordingsLocalOptIn(env);
|
|
4018
|
+
}
|
|
4019
|
+
function recordingsResolverEnv(env) {
|
|
4020
|
+
const carved = RECORDINGS_CARVED_LEGACY_ENV_KEYS.filter((key) => (key in env));
|
|
4021
|
+
const blanks = recordingsAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
|
|
4022
|
+
const removed = new Set([...carved, ...blanks]);
|
|
4023
|
+
if (removed.size === 0)
|
|
4024
|
+
return env;
|
|
4025
|
+
const next = { ...env };
|
|
4026
|
+
for (const key of removed)
|
|
4027
|
+
delete next[key];
|
|
4028
|
+
return next;
|
|
4029
|
+
}
|
|
4030
|
+
var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
4031
|
+
function isAmbientRecordingsEnv(env) {
|
|
4032
|
+
if (typeof process !== "undefined" && env === process.env) {
|
|
4033
|
+
return true;
|
|
4034
|
+
}
|
|
4035
|
+
return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
|
|
4036
|
+
}
|
|
4037
|
+
function recordingsResolverInputs(env, credentials = {}) {
|
|
4038
|
+
const normalised = recordingsResolverEnv(env);
|
|
4039
|
+
if (normalised === env)
|
|
4040
|
+
return { env: normalised, credentials };
|
|
4041
|
+
const keychain = { ...credentials.keychain };
|
|
4042
|
+
if (keychain.enabled === undefined && keychain.run === undefined) {
|
|
4043
|
+
keychain.enabled = isAmbientRecordingsEnv(env);
|
|
4044
|
+
}
|
|
4045
|
+
return { env: normalised, credentials: { ...credentials, keychain } };
|
|
4046
|
+
}
|
|
2944
4047
|
|
|
2945
4048
|
// src/http/client.ts
|
|
2946
|
-
|
|
2947
|
-
|
|
4049
|
+
var RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
4050
|
+
var IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
4051
|
+
function throwRecordingsAuthorityFailure(error) {
|
|
4052
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4053
|
+
const name = error instanceof Error ? error.name : "";
|
|
4054
|
+
if (name === "CredentialResolutionError" || name === "CredentialFileUnsafeError") {
|
|
4055
|
+
throw new Error(`REMOTE_API_CREDENTIAL_INVALID: ${message} There is no local fallback: ` + "local SQLite is opt-in only (HASNA_RECORDINGS_LOCAL=1) and is disabled by default \u2014 failing closed", { cause: error });
|
|
4056
|
+
}
|
|
4057
|
+
if (/no API key could be resolved/.test(message)) {
|
|
4058
|
+
if (/is not set and no API key could be resolved/.test(message)) {
|
|
4059
|
+
throw new Error("REMOTE_API_CONFIG_MISSING: no Recordings credential resolved from the Keychain item " + `hasna.credentials.recordings.api-key, ~/.hasna/recordings/config/credentials, or HASNA_RECORDINGS_API_KEY. ${message} ` + "There is no local fallback: local SQLite is opt-in only (HASNA_RECORDINGS_LOCAL=1, alias RECORDINGS_LOCAL=1) " + "and is disabled by default \u2014 failing closed instead of serving the local store", { cause: error });
|
|
4060
|
+
}
|
|
4061
|
+
throw new Error("REMOTE_API_KEY_MISSING: remote Recordings storage requires HASNA_RECORDINGS_API_KEY, the Keychain item " + `hasna.credentials.recordings.api-key, or ~/.hasna/recordings/config/credentials. ${message} ` + "There is no local fallback: local SQLite is opt-in only (HASNA_RECORDINGS_LOCAL=1) and is disabled by default \u2014 failing closed", { cause: error });
|
|
4062
|
+
}
|
|
4063
|
+
throw new Error(`REMOTE_API_URL_INVALID: ${message} local SQLite fallback is disabled`, { cause: error });
|
|
2948
4064
|
}
|
|
2949
|
-
function
|
|
2950
|
-
const token = envToken2(name);
|
|
4065
|
+
function toRecordsAuthority(resolution) {
|
|
2951
4066
|
return {
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
4067
|
+
transport: resolution.transport,
|
|
4068
|
+
transportSource: resolution.transportSource,
|
|
4069
|
+
baseUrl: resolution.baseUrl,
|
|
4070
|
+
apiUrlSource: resolution.apiUrlSource,
|
|
4071
|
+
apiKeyPresent: resolution.apiKeyPresent,
|
|
4072
|
+
apiKeySource: resolution.apiKeySource,
|
|
4073
|
+
apiKeyTier: resolution.apiKeyTier,
|
|
4074
|
+
misconfigured: resolution.misconfigured,
|
|
4075
|
+
warning: resolution.warning
|
|
2955
4076
|
};
|
|
2956
4077
|
}
|
|
2957
|
-
function
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
return "sqlite";
|
|
2961
|
-
if (normalized === "http" || normalized === "https")
|
|
2962
|
-
return "http";
|
|
2963
|
-
throw new Error(`Unknown client store: ${value}. Use sqlite or http.`);
|
|
2964
|
-
}
|
|
2965
|
-
function firstEnv2(env, keys) {
|
|
2966
|
-
for (const key of keys) {
|
|
2967
|
-
const value = env[key]?.trim();
|
|
2968
|
-
if (value)
|
|
2969
|
-
return { key, value };
|
|
4078
|
+
function resolveRecordingsTransport(env = process.env, options = {}) {
|
|
4079
|
+
if (selectsRecordingsLocalStore(env)) {
|
|
4080
|
+
return { transport: "sqlite", selected: false, source: "local-opt-in", authority: null };
|
|
2970
4081
|
}
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
4082
|
+
const resolverInputs = recordingsResolverInputs(env, options.credentials);
|
|
4083
|
+
let resolution;
|
|
4084
|
+
try {
|
|
4085
|
+
resolution = resolveClientTransport("recordings", resolverInputs.env, {
|
|
4086
|
+
credentials: resolverInputs.credentials
|
|
4087
|
+
});
|
|
4088
|
+
} catch (error) {
|
|
4089
|
+
throwRecordingsAuthorityFailure(error);
|
|
2977
4090
|
}
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
return url.toString().replace(/\/+$/, "");
|
|
4091
|
+
return {
|
|
4092
|
+
transport: "http",
|
|
4093
|
+
selected: true,
|
|
4094
|
+
source: `${resolution.apiKeySource ?? resolution.apiKeyTier}+${resolution.apiUrlSource ?? "default"}`,
|
|
4095
|
+
authority: toRecordsAuthority(resolution)
|
|
4096
|
+
};
|
|
2985
4097
|
}
|
|
2986
|
-
function
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
requested = normalizeClientStore(storeHit.value);
|
|
2995
|
-
modeSource = storeHit.key;
|
|
2996
|
-
} else if (urlHit && keyHit) {
|
|
2997
|
-
requested = "http";
|
|
2998
|
-
modeSource = "auto:api-url+api-key";
|
|
2999
|
-
} else if (urlHit || keyHit) {
|
|
3000
|
-
const missing = urlHit ? keys.apiKeyKeys[0] : keys.apiUrlKeys[0];
|
|
3001
|
-
const present = urlHit ? keys.apiUrlKeys[0] : keys.apiKeyKeys[0];
|
|
4098
|
+
function getRecordingsTransportStatus(env = process.env, options = {}) {
|
|
4099
|
+
let resolution;
|
|
4100
|
+
try {
|
|
4101
|
+
resolution = resolveRecordingsTransport(env, options);
|
|
4102
|
+
} catch (error) {
|
|
4103
|
+
const issue = error instanceof Error ? error.message : String(error);
|
|
4104
|
+
const envKeys = clientTransportEnvKeys("recordings");
|
|
4105
|
+
const declared = (keys) => keys.some((key) => (env[key] ?? "").trim() !== "");
|
|
3002
4106
|
return {
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
4107
|
+
selected: true,
|
|
4108
|
+
ok: false,
|
|
4109
|
+
transport: "invalid",
|
|
4110
|
+
api_url_configured: declared(envKeys.apiUrlKeys),
|
|
4111
|
+
api_key_configured: declared(envKeys.apiKeyKeys),
|
|
4112
|
+
api_url_source: null,
|
|
4113
|
+
api_key_source: null,
|
|
4114
|
+
api_key_tier: null,
|
|
4115
|
+
v1_base_url: null,
|
|
4116
|
+
issues: [issue],
|
|
4117
|
+
local_fallback: false
|
|
3010
4118
|
};
|
|
3011
4119
|
}
|
|
3012
|
-
if (
|
|
3013
|
-
return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
|
|
3014
|
-
}
|
|
3015
|
-
if (!urlHit) {
|
|
4120
|
+
if (!resolution.selected) {
|
|
3016
4121
|
return {
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
4122
|
+
selected: false,
|
|
4123
|
+
ok: true,
|
|
4124
|
+
transport: resolution.transport,
|
|
4125
|
+
api_url_configured: false,
|
|
4126
|
+
api_key_configured: false,
|
|
4127
|
+
api_url_source: null,
|
|
4128
|
+
api_key_source: null,
|
|
4129
|
+
api_key_tier: null,
|
|
4130
|
+
v1_base_url: null,
|
|
4131
|
+
issues: [],
|
|
4132
|
+
local_fallback: false
|
|
3024
4133
|
};
|
|
3025
4134
|
}
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
4135
|
+
const authority = resolution.authority;
|
|
4136
|
+
return {
|
|
4137
|
+
selected: true,
|
|
4138
|
+
ok: true,
|
|
4139
|
+
transport: resolution.transport,
|
|
4140
|
+
api_url_configured: authority.apiUrlSource !== null && authority.apiUrlSource !== "default",
|
|
4141
|
+
api_key_configured: authority.apiKeyPresent,
|
|
4142
|
+
api_url_source: authority.apiUrlSource,
|
|
4143
|
+
api_key_source: authority.apiKeySource,
|
|
4144
|
+
api_key_tier: authority.apiKeyTier,
|
|
4145
|
+
v1_base_url: authority.baseUrl,
|
|
4146
|
+
issues: [],
|
|
4147
|
+
local_fallback: false
|
|
4148
|
+
};
|
|
4149
|
+
}
|
|
4150
|
+
function resolveRecordingsCloudClient(env = process.env, options = {}) {
|
|
4151
|
+
if (selectsRecordingsLocalStore(env))
|
|
4152
|
+
return null;
|
|
4153
|
+
const resolverInputs = recordingsResolverInputs(env, options.credentials);
|
|
4154
|
+
let resolved;
|
|
4155
|
+
try {
|
|
4156
|
+
resolved = resolveStorageClient("recordings", resolverInputs.env, {
|
|
4157
|
+
fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" }),
|
|
4158
|
+
credentials: resolverInputs.credentials
|
|
4159
|
+
});
|
|
4160
|
+
} catch (error) {
|
|
4161
|
+
throwRecordingsAuthorityFailure(error);
|
|
3036
4162
|
}
|
|
3037
|
-
const
|
|
3038
|
-
|
|
4163
|
+
const client = resolved.client;
|
|
4164
|
+
return client;
|
|
4165
|
+
}
|
|
4166
|
+
|
|
4167
|
+
// src/store.ts
|
|
4168
|
+
import { createHash as createHash2, randomUUID as randomUUID5 } from "crypto";
|
|
4169
|
+
|
|
4170
|
+
// src/lib/audio-artifact-storage.ts
|
|
4171
|
+
import { GetObjectCommand, PutObjectCommand, S3Client as AwsS3Client } from "@aws-sdk/client-s3";
|
|
4172
|
+
import { createHash } from "crypto";
|
|
4173
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
4174
|
+
import { extname } from "path";
|
|
4175
|
+
var SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
4176
|
+
var DEFAULT_S3_PREFIX = "recordings";
|
|
4177
|
+
var AUDIO_CONTENT_TYPES = {
|
|
4178
|
+
wav: "audio/wav",
|
|
4179
|
+
mp3: "audio/mpeg",
|
|
4180
|
+
m4a: "audio/mp4",
|
|
4181
|
+
aac: "audio/aac",
|
|
4182
|
+
webm: "audio/webm",
|
|
4183
|
+
ogg: "audio/ogg",
|
|
4184
|
+
flac: "audio/flac"
|
|
4185
|
+
};
|
|
4186
|
+
function contentTypeForExtension(extension) {
|
|
4187
|
+
return AUDIO_CONTENT_TYPES[extension.toLowerCase()] ?? "application/octet-stream";
|
|
4188
|
+
}
|
|
4189
|
+
|
|
4190
|
+
class AudioArtifactStorage {
|
|
4191
|
+
bucket;
|
|
4192
|
+
prefix;
|
|
4193
|
+
s3;
|
|
4194
|
+
constructor(options = {}) {
|
|
4195
|
+
this.bucket = options.bucket;
|
|
4196
|
+
this.prefix = (options.prefix || DEFAULT_S3_PREFIX).replace(/^\/+|\/+$/g, "");
|
|
4197
|
+
this.s3 = this.bucket ? options.client ?? new AwsS3Client({ region: options.region || process.env.AWS_REGION || "us-east-1" }) : undefined;
|
|
4198
|
+
}
|
|
4199
|
+
get usesS3() {
|
|
4200
|
+
return Boolean(this.bucket);
|
|
4201
|
+
}
|
|
4202
|
+
objectKeyFor(recordingId, sha256, extension) {
|
|
4203
|
+
assertSha256(sha256);
|
|
4204
|
+
const safeExtension = extension.toLowerCase().match(/^[a-z0-9]+/)?.[0] || "bin";
|
|
4205
|
+
return `${this.prefix}/${recordingId}/${sha256}.${safeExtension}`;
|
|
4206
|
+
}
|
|
4207
|
+
async uploadAudio(recordingId, audioPath) {
|
|
4208
|
+
if (!this.bucket || !this.s3)
|
|
4209
|
+
return null;
|
|
4210
|
+
const bytes = await readFileBytes(audioPath);
|
|
4211
|
+
if (!bytes)
|
|
4212
|
+
return null;
|
|
4213
|
+
const sha256 = sha256Hex(bytes);
|
|
4214
|
+
const extension = extensionOf(audioPath);
|
|
4215
|
+
const key = this.objectKeyFor(recordingId, sha256, extension);
|
|
4216
|
+
await this.s3.send(new PutObjectCommand({
|
|
4217
|
+
Bucket: this.bucket,
|
|
4218
|
+
Key: key,
|
|
4219
|
+
Body: bytes,
|
|
4220
|
+
ContentType: contentTypeForExtension(extension)
|
|
4221
|
+
}));
|
|
4222
|
+
return { objectKey: key, sha256, bytes: bytes.byteLength };
|
|
4223
|
+
}
|
|
4224
|
+
async readAudio(objectKey) {
|
|
4225
|
+
if (!this.bucket || !this.s3)
|
|
4226
|
+
return null;
|
|
4227
|
+
try {
|
|
4228
|
+
const response = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }));
|
|
4229
|
+
return await bodyToBytes(response.Body);
|
|
4230
|
+
} catch {
|
|
4231
|
+
return null;
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4235
|
+
function resolveAudioArtifactStorage(env = process.env, client) {
|
|
4236
|
+
const bucket = env.HASNA_RECORDINGS_S3_BUCKET || env.RECORDINGS_S3_BUCKET || undefined;
|
|
4237
|
+
const prefix = env.HASNA_RECORDINGS_S3_PREFIX || env.RECORDINGS_S3_PREFIX || undefined;
|
|
4238
|
+
const region = env.RECORDINGS_S3_REGION || env.AWS_REGION || undefined;
|
|
4239
|
+
return new AudioArtifactStorage({ bucket, prefix, region, client });
|
|
4240
|
+
}
|
|
4241
|
+
async function uploadAudioAtCreation(recordingId, audioPath, storage = resolveAudioArtifactStorage()) {
|
|
4242
|
+
if (!audioPath)
|
|
4243
|
+
return null;
|
|
4244
|
+
if (!storage.usesS3)
|
|
4245
|
+
return null;
|
|
3039
4246
|
try {
|
|
3040
|
-
|
|
4247
|
+
const uploaded = await storage.uploadAudio(recordingId, audioPath);
|
|
4248
|
+
if (!uploaded)
|
|
4249
|
+
return null;
|
|
4250
|
+
console.warn(`recordings: uploaded audio for ${recordingId} to ${uploaded.objectKey}`);
|
|
4251
|
+
return uploaded;
|
|
3041
4252
|
} catch (error) {
|
|
3042
|
-
|
|
3043
|
-
return
|
|
4253
|
+
console.warn(`recordings: audio upload failed for ${recordingId} (${String(error instanceof Error ? error.message : error)}); keeping local audio_path only`);
|
|
4254
|
+
return null;
|
|
3044
4255
|
}
|
|
3045
|
-
return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
|
|
3046
4256
|
}
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
4257
|
+
function assertSha256(value) {
|
|
4258
|
+
if (!SHA256_HEX.test(value)) {
|
|
4259
|
+
throw new Error("audio digest must be a lowercase hex sha-256; refusing to build a storage key from it");
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
function sha256Hex(bytes) {
|
|
4263
|
+
const hash = createHash("sha256");
|
|
4264
|
+
hash.update(bytes);
|
|
4265
|
+
return hash.digest("hex");
|
|
4266
|
+
}
|
|
4267
|
+
async function readFileBytes(path) {
|
|
4268
|
+
try {
|
|
4269
|
+
return new Uint8Array(await readFile2(path));
|
|
4270
|
+
} catch {
|
|
4271
|
+
return null;
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
function extensionOf(path) {
|
|
4275
|
+
const extension = extname(path).replace(/^\./, "").toLowerCase();
|
|
4276
|
+
return extension || "bin";
|
|
4277
|
+
}
|
|
4278
|
+
async function bodyToBytes(body) {
|
|
4279
|
+
if (!body)
|
|
4280
|
+
return new Uint8Array(0);
|
|
4281
|
+
if (body instanceof Uint8Array)
|
|
4282
|
+
return body;
|
|
4283
|
+
if (typeof body.transformToByteArray === "function") {
|
|
4284
|
+
return new Uint8Array(await body.transformToByteArray());
|
|
4285
|
+
}
|
|
4286
|
+
if (typeof body.getReader === "function") {
|
|
4287
|
+
const reader = body.getReader();
|
|
4288
|
+
const chunks2 = [];
|
|
4289
|
+
for (;; ) {
|
|
4290
|
+
const { value, done } = await reader.read();
|
|
4291
|
+
if (done)
|
|
4292
|
+
break;
|
|
4293
|
+
if (value)
|
|
4294
|
+
chunks2.push(value);
|
|
3067
4295
|
}
|
|
3068
|
-
|
|
4296
|
+
return concat(chunks2);
|
|
3069
4297
|
}
|
|
3070
|
-
|
|
3071
|
-
|
|
4298
|
+
const chunks = [];
|
|
4299
|
+
for await (const chunk of body) {
|
|
4300
|
+
chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
3072
4301
|
}
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
4302
|
+
return concat(chunks);
|
|
4303
|
+
}
|
|
4304
|
+
function concat(chunks) {
|
|
4305
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
4306
|
+
const merged = new Uint8Array(new ArrayBuffer(total));
|
|
4307
|
+
let offset = 0;
|
|
4308
|
+
for (const chunk of chunks) {
|
|
4309
|
+
merged.set(chunk, offset);
|
|
4310
|
+
offset += chunk.byteLength;
|
|
3076
4311
|
}
|
|
3077
|
-
return
|
|
4312
|
+
return merged;
|
|
3078
4313
|
}
|
|
3079
4314
|
|
|
3080
4315
|
// src/store.ts
|
|
3081
|
-
import { createHash, randomUUID as randomUUID5 } from "crypto";
|
|
3082
|
-
var APP = "recordings";
|
|
3083
4316
|
function listQuery(filter) {
|
|
3084
4317
|
if (!filter)
|
|
3085
4318
|
return {};
|
|
@@ -3109,7 +4342,15 @@ var localStore = {
|
|
|
3109
4342
|
mode: "sqlite",
|
|
3110
4343
|
baseUrl: null,
|
|
3111
4344
|
async createRecording(input, idempotencyKey) {
|
|
3112
|
-
return withLocalStoreReaderLease(() =>
|
|
4345
|
+
return withLocalStoreReaderLease(async () => {
|
|
4346
|
+
const recording = createRecording(input, undefined, idempotencyKey);
|
|
4347
|
+
if (!recording.audio_path)
|
|
4348
|
+
return recording;
|
|
4349
|
+
const uploaded = await uploadAudioAtCreation(recording.id, recording.audio_path, localArtifactStorageFor());
|
|
4350
|
+
if (!uploaded)
|
|
4351
|
+
return recording;
|
|
4352
|
+
return updateRecordingAudio(recording.id, uploaded.objectKey, uploaded.sha256, uploaded.bytes) ?? recording;
|
|
4353
|
+
});
|
|
3113
4354
|
},
|
|
3114
4355
|
async getRecording(id) {
|
|
3115
4356
|
return withLocalStoreReaderLease(() => getRecording(id));
|
|
@@ -3303,7 +4544,7 @@ function apiStore(client) {
|
|
|
3303
4544
|
};
|
|
3304
4545
|
}
|
|
3305
4546
|
function recordingPageFingerprint(items) {
|
|
3306
|
-
const hash =
|
|
4547
|
+
const hash = createHash2("sha256");
|
|
3307
4548
|
const ids = items.map((item) => String(item.id ?? "")).sort();
|
|
3308
4549
|
for (const id of ids) {
|
|
3309
4550
|
hash.update(String(id.length));
|
|
@@ -3339,19 +4580,23 @@ async function countStoreRecordings(store, filter) {
|
|
|
3339
4580
|
throw new Error(`Legacy Store exceeded ${maxPageRequests} pages while counting recordings`);
|
|
3340
4581
|
}
|
|
3341
4582
|
var cached = null;
|
|
3342
|
-
|
|
3343
|
-
|
|
4583
|
+
var localArtifactStorageOverride;
|
|
4584
|
+
function localArtifactStorageFor() {
|
|
4585
|
+
return localArtifactStorageOverride ?? resolveAudioArtifactStorage();
|
|
4586
|
+
}
|
|
4587
|
+
function getStore(env = process.env, options = {}) {
|
|
4588
|
+
if (env === process.env && cached && options.credentials === undefined)
|
|
3344
4589
|
return cached;
|
|
3345
|
-
const
|
|
3346
|
-
const store =
|
|
3347
|
-
if (env === process.env)
|
|
4590
|
+
const client = resolveRecordingsCloudClient(env, options);
|
|
4591
|
+
const store = client ? apiStore(client) : localStore;
|
|
4592
|
+
if (env === process.env && options.credentials === undefined)
|
|
3348
4593
|
cached = store;
|
|
3349
4594
|
return store;
|
|
3350
4595
|
}
|
|
3351
4596
|
|
|
3352
4597
|
// src/lib/recorder.ts
|
|
3353
4598
|
import { spawn as spawn2 } from "child_process";
|
|
3354
|
-
import { join as
|
|
4599
|
+
import { join as join6 } from "path";
|
|
3355
4600
|
import { existsSync as existsSync3 } from "fs";
|
|
3356
4601
|
|
|
3357
4602
|
// src/types/index.ts
|
|
@@ -3402,7 +4647,7 @@ function startRecording(config) {
|
|
|
3402
4647
|
}
|
|
3403
4648
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
3404
4649
|
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
3405
|
-
const filepath =
|
|
4650
|
+
const filepath = join6(config.audio_dir, filename);
|
|
3406
4651
|
const args = buildRecordArgs(filepath, config);
|
|
3407
4652
|
const [command, ...commandArgs] = args;
|
|
3408
4653
|
if (command === undefined) {
|
|
@@ -3474,7 +4719,7 @@ function buildRecordArgs(filepath, config) {
|
|
|
3474
4719
|
async function recordDuration(seconds, config) {
|
|
3475
4720
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
3476
4721
|
const filename = `recording-${timestamp}.${config.audio_format}`;
|
|
3477
|
-
const filepath =
|
|
4722
|
+
const filepath = join6(config.audio_dir, filename);
|
|
3478
4723
|
const args = [
|
|
3479
4724
|
"rec",
|
|
3480
4725
|
"-r",
|
|
@@ -3512,7 +4757,7 @@ var _client = null;
|
|
|
3512
4757
|
var _clientApiKey = null;
|
|
3513
4758
|
function getClient(config) {
|
|
3514
4759
|
if (!config.openai_api_key) {
|
|
3515
|
-
throw new TranscriptionError("OpenAI API key not configured. Set OPENAI_API_KEY env var
|
|
4760
|
+
throw new TranscriptionError("OpenAI API key not configured. Set OPENAI_API_KEY env var (e.g. " + "'secrets exec <key> --as OPENAI_API_KEY -- recordings \u2026')");
|
|
3516
4761
|
}
|
|
3517
4762
|
if (_client && _clientApiKey === config.openai_api_key)
|
|
3518
4763
|
return _client;
|
|
@@ -3537,7 +4782,7 @@ async function verifyTranscriptionCredential(config, model, options = {}) {
|
|
|
3537
4782
|
status: null,
|
|
3538
4783
|
model,
|
|
3539
4784
|
role,
|
|
3540
|
-
message: `no API key configured for the ${role} role. ` + "Set OPENAI_API_KEY env var
|
|
4785
|
+
message: `no API key configured for the ${role} role. ` + "Set OPENAI_API_KEY env var (e.g. 'secrets exec <key> --as OPENAI_API_KEY -- recordings \u2026')"
|
|
3541
4786
|
};
|
|
3542
4787
|
}
|
|
3543
4788
|
try {
|
|
@@ -3646,9 +4891,9 @@ ${trimmed}`;
|
|
|
3646
4891
|
}
|
|
3647
4892
|
|
|
3648
4893
|
// src/lib/capture-probe.ts
|
|
3649
|
-
import { spawnSync as
|
|
3650
|
-
import { existsSync as existsSync4, readFileSync as
|
|
3651
|
-
import { join as
|
|
4894
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
4895
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, rmSync as rmSync2 } from "fs";
|
|
4896
|
+
import { join as join7 } from "path";
|
|
3652
4897
|
import { tmpdir } from "os";
|
|
3653
4898
|
|
|
3654
4899
|
// src/lib/macos-bundle.ts
|
|
@@ -3664,7 +4909,7 @@ var WAVE_FORMAT_PCM = 1;
|
|
|
3664
4909
|
var WAVE_FORMAT_EXTENSIBLE = 65534;
|
|
3665
4910
|
var SUBFORMAT_OFFSET_IN_EXTENSION = 8;
|
|
3666
4911
|
function readWavPeak(filepath) {
|
|
3667
|
-
const buf =
|
|
4912
|
+
const buf = readFileSync4(filepath);
|
|
3668
4913
|
if (buf.length < RIFF_HEADER_BYTES) {
|
|
3669
4914
|
throw new Error(`not a RIFF file (${buf.length} bytes): ${filepath}`);
|
|
3670
4915
|
}
|
|
@@ -3739,9 +4984,9 @@ function probeMicrophoneCapture(config, options = {}) {
|
|
|
3739
4984
|
peak: 0,
|
|
3740
4985
|
silent: null
|
|
3741
4986
|
};
|
|
3742
|
-
const filepath =
|
|
4987
|
+
const filepath = join7(tmpdir(), `recordings-capture-probe-${process.pid}-${Date.now()}.wav`);
|
|
3743
4988
|
try {
|
|
3744
|
-
const result =
|
|
4989
|
+
const result = spawnSync4(executable, [
|
|
3745
4990
|
"-q",
|
|
3746
4991
|
"-r",
|
|
3747
4992
|
String(config.sample_rate),
|
|
@@ -3839,37 +5084,37 @@ function captureProbeSubject(env = process.env, options = {}) {
|
|
|
3839
5084
|
const termProgram = env.TERM_PROGRAM?.trim();
|
|
3840
5085
|
const hasTty = options.hasTty ?? Boolean(process.stdin.isTTY || process.stdout.isTTY);
|
|
3841
5086
|
if (overSsh) {
|
|
3842
|
-
const subject2 = "the SSH session (sshd), not
|
|
5087
|
+
const subject2 = "the SSH session (sshd), not Hasna Recordings.app";
|
|
3843
5088
|
return {
|
|
3844
5089
|
headless: true,
|
|
3845
5090
|
subject_known: true,
|
|
3846
5091
|
subject: subject2,
|
|
3847
|
-
note: "Running over SSH: macOS cannot display a consent prompt to a session with no GUI, " + "so Microphone stays not_determined and a silent capture here says NOTHING about " + "whether
|
|
5092
|
+
note: "Running over SSH: macOS cannot display a consent prompt to a session with no GUI, " + "so Microphone stays not_determined and a silent capture here says NOTHING about " + "whether Hasna Recordings.app can record. Judge the app by its own TCC entry and its log."
|
|
3848
5093
|
};
|
|
3849
5094
|
}
|
|
3850
5095
|
if (inTmux) {
|
|
3851
|
-
const subject2 = "the tmux server, not
|
|
5096
|
+
const subject2 = "the tmux server, not Hasna Recordings.app and not this pane's shell";
|
|
3852
5097
|
return {
|
|
3853
5098
|
headless: false,
|
|
3854
5099
|
subject_known: true,
|
|
3855
5100
|
subject: subject2,
|
|
3856
|
-
note: "Running inside tmux: TCC attributes this capture to the tmux binary, which holds its " + "own grant. tmux also snapshots the environment at pane creation, so SSH variables may " + "be missing even in a remote session \u2014 treat a silent result as inconclusive about both " + "
|
|
5101
|
+
note: "Running inside tmux: TCC attributes this capture to the tmux binary, which holds its " + "own grant. tmux also snapshots the environment at pane creation, so SSH variables may " + "be missing even in a remote session \u2014 treat a silent result as inconclusive about both " + "Hasna Recordings.app and about whether anyone could have been prompted."
|
|
3857
5102
|
};
|
|
3858
5103
|
}
|
|
3859
5104
|
if (!termProgram && !hasTty) {
|
|
3860
5105
|
return {
|
|
3861
5106
|
headless: true,
|
|
3862
5107
|
subject_known: false,
|
|
3863
|
-
subject: "an unidentified responsible process (not
|
|
3864
|
-
note: "The responsible process could not be identified: no SSH variables, no TERM_PROGRAM and " + "no tty, which is what launchd, cron, CI and sudo look like. Whatever holds the grant, it " + "is not
|
|
5108
|
+
subject: "an unidentified responsible process (not Hasna Recordings.app)",
|
|
5109
|
+
note: "The responsible process could not be identified: no SSH variables, no TERM_PROGRAM and " + "no tty, which is what launchd, cron, CI and sudo look like. Whatever holds the grant, it " + "is not Hasna Recordings.app, and nothing here can be shown a consent prompt. Inconclusive."
|
|
3865
5110
|
};
|
|
3866
5111
|
}
|
|
3867
|
-
const subject = `${termProgram || "the terminal application running this command"}, not
|
|
5112
|
+
const subject = `${termProgram || "the terminal application running this command"}, not Hasna Recordings.app`;
|
|
3868
5113
|
return {
|
|
3869
5114
|
headless: false,
|
|
3870
5115
|
subject_known: Boolean(termProgram),
|
|
3871
5116
|
subject,
|
|
3872
|
-
note: `Grants are per responsible process: this probe exercises ${subject}. ` + "A pass proves the microphone hardware and the input device work; it does not " + "transfer to
|
|
5117
|
+
note: `Grants are per responsible process: this probe exercises ${subject}. ` + "A pass proves the microphone hardware and the input device work; it does not " + "transfer to Hasna Recordings.app, which needs its own grant."
|
|
3873
5118
|
};
|
|
3874
5119
|
}
|
|
3875
5120
|
var TCC_UNREADABLE_STATE = TCC_DATABASE_UNREADABLE_STATE;
|
|
@@ -3899,7 +5144,7 @@ function microphoneGrantInstruction(options) {
|
|
|
3899
5144
|
const bundlePath = candidates[0] ?? null;
|
|
3900
5145
|
const steps = [];
|
|
3901
5146
|
if (!bundlePath) {
|
|
3902
|
-
steps.push("No
|
|
5147
|
+
steps.push("No Hasna Recordings.app bundle was found on disk, so there is nothing to grant Microphone to yet. " + "Install the app first ('recordings app install').");
|
|
3903
5148
|
return {
|
|
3904
5149
|
bundle_path: null,
|
|
3905
5150
|
bundle_identifier: RECORDINGS_BUNDLE_IDENTIFIER,
|
|
@@ -3908,10 +5153,10 @@ function microphoneGrantInstruction(options) {
|
|
|
3908
5153
|
};
|
|
3909
5154
|
}
|
|
3910
5155
|
if (candidates.length > 1) {
|
|
3911
|
-
steps.push(`AMBIGUOUS: ${candidates.length}
|
|
5156
|
+
steps.push(`AMBIGUOUS: ${candidates.length} Hasna Recordings.app bundles exist (${candidates.join(", ")}). ` + "A TCC grant is bound to the bundle's code signature, so granting one does not grant the " + "other, and the toggle in Settings does not say which is which. Remove the bundles you are " + "not running before granting, or the grant may attach to the wrong one.");
|
|
3912
5157
|
}
|
|
3913
5158
|
steps.push(`At the keyboard on the machine itself (not over SSH), launch ${bundlePath} and start a ` + "recording once. macOS shows the consent sheet titled " + `"\u201CRecordings\u201D would like to access the microphone" \u2014 click Allow.`);
|
|
3914
|
-
steps.push("If no sheet appears, open System Settings \u2192 Privacy & Security \u2192 Microphone " + "and switch ON the row named \u201CRecordings\u201D. " + `That row is bundle ${RECORDINGS_BUNDLE_IDENTIFIER} at ${bundlePath}; the binary that ` + `receives the grant is ${
|
|
5159
|
+
steps.push("If no sheet appears, open System Settings \u2192 Privacy & Security \u2192 Microphone " + "and switch ON the row named \u201CRecordings\u201D. " + `That row is bundle ${RECORDINGS_BUNDLE_IDENTIFIER} at ${bundlePath}; the binary that ` + `receives the grant is ${join7(bundlePath, "Contents", "MacOS", "Recordings")}.`);
|
|
3915
5160
|
if (options.requestState === "never_requested") {
|
|
3916
5161
|
steps.push("Note: the app has never requested microphone access on this machine (no TCC entry exists), " + "so the Microphone list will NOT contain a \u201CRecordings\u201D row until the app asks once. " + "Do the launch-and-record step first; the Settings toggle only exists afterwards.");
|
|
3917
5162
|
} else if (options.requestState === "unknown") {
|
|
@@ -3930,7 +5175,7 @@ function microphoneGrantInstruction(options) {
|
|
|
3930
5175
|
// src/lib/persistence-probe.ts
|
|
3931
5176
|
import { Database as Database2 } from "bun:sqlite";
|
|
3932
5177
|
import { existsSync as existsSync5 } from "fs";
|
|
3933
|
-
var
|
|
5178
|
+
var ENV_CREDENTIAL_MODE_SOURCE = "env";
|
|
3934
5179
|
function safeBaseUrl(baseUrl) {
|
|
3935
5180
|
if (!baseUrl)
|
|
3936
5181
|
return baseUrl;
|
|
@@ -3974,42 +5219,51 @@ function localStoreIsBehindSchema(dbPath) {
|
|
|
3974
5219
|
return null;
|
|
3975
5220
|
}
|
|
3976
5221
|
}
|
|
3977
|
-
function describeActiveStore(config, env = process.env) {
|
|
5222
|
+
function describeActiveStore(config, env = process.env, options = {}) {
|
|
3978
5223
|
const localDbPath = config.db_path;
|
|
3979
5224
|
const localDbPresent = existsSync5(localDbPath);
|
|
3980
|
-
const
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
resolution = resolveTransport(APP, env);
|
|
3984
|
-
} catch (error) {
|
|
5225
|
+
const status = getRecordingsTransportStatus(env, options);
|
|
5226
|
+
if (!status.ok) {
|
|
5227
|
+
const issues = status.issues.length > 0 ? status.issues.join(" ") : null;
|
|
3985
5228
|
return {
|
|
3986
|
-
transport: "
|
|
5229
|
+
transport: "none",
|
|
3987
5230
|
mode_source: "unresolved",
|
|
3988
5231
|
base_url: null,
|
|
3989
5232
|
local_db_path: localDbPath,
|
|
3990
5233
|
local_db_present: localDbPresent,
|
|
3991
|
-
local_db_recordings:
|
|
5234
|
+
local_db_recordings: null,
|
|
5235
|
+
divergent: false,
|
|
5236
|
+
warning: issues ? redactKeyMaterial(issues) : null
|
|
5237
|
+
};
|
|
5238
|
+
}
|
|
5239
|
+
if (!status.selected) {
|
|
5240
|
+
return {
|
|
5241
|
+
transport: "sqlite",
|
|
5242
|
+
mode_source: "local-opt-in",
|
|
5243
|
+
base_url: null,
|
|
5244
|
+
local_db_path: localDbPath,
|
|
5245
|
+
local_db_present: localDbPresent,
|
|
5246
|
+
local_db_recordings: localDbPresent ? readLocalRecordingCount(localDbPath) : null,
|
|
3992
5247
|
divergent: false,
|
|
3993
|
-
warning:
|
|
5248
|
+
warning: null
|
|
3994
5249
|
};
|
|
3995
5250
|
}
|
|
3996
|
-
const
|
|
5251
|
+
const localDbRecordings = localDbPresent ? readLocalRecordingCount(localDbPath) : null;
|
|
5252
|
+
const divergent = (localDbRecordings ?? 0) > 0;
|
|
3997
5253
|
const warnings = [];
|
|
3998
|
-
if (
|
|
3999
|
-
warnings.push(
|
|
4000
|
-
if (resolution.transport === "http" && localDbPresent && localDbRecordings === null) {
|
|
4001
|
-
warnings.push(`writes go to ${safeBaseUrl(resolution.baseUrl)}, and ${localDbPath} exists but could not be counted, ` + "so whether a second dataset is sitting there is UNKNOWN \u2014 not 'none'.");
|
|
5254
|
+
if (status.api_key_tier === ENV_CREDENTIAL_MODE_SOURCE) {
|
|
5255
|
+
warnings.push("the credential came from the process environment (HASNA_RECORDINGS_API_KEY): a shell export is a " + "snapshot taken when the shell started, so a rotation stays invisible until that shell exits. " + "Check `launchctl getenv HASNA_RECORDINGS_API_KEY` too: a launchd session variable " + "is inherited by the GUI app as well as by shells, and is invisible in a login profile. " + "Prefer the Keychain item hasna.credentials.recordings.api-key or " + "~/.hasna/recordings/config/credentials, both re-read on every call.");
|
|
4002
5256
|
}
|
|
4003
|
-
if (
|
|
4004
|
-
warnings.push(`writes go to ${safeBaseUrl(
|
|
5257
|
+
if (localDbPresent && localDbRecordings === null) {
|
|
5258
|
+
warnings.push(`writes go to ${safeBaseUrl(status.v1_base_url)}, and ${localDbPath} exists but could not be counted, ` + "so whether a second dataset is sitting there is UNKNOWN \u2014 not 'none'.");
|
|
4005
5259
|
}
|
|
4006
|
-
if (
|
|
4007
|
-
warnings.push(
|
|
5260
|
+
if (divergent) {
|
|
5261
|
+
warnings.push(`writes go to ${safeBaseUrl(status.v1_base_url)}, but ${localDbPath} still holds ` + `${localDbRecordings} recordings from an earlier on-box-only period. ` + "That file is NOT the live store \u2014 auditing it undercounts and looks like data loss.");
|
|
4008
5262
|
}
|
|
4009
5263
|
return {
|
|
4010
|
-
transport:
|
|
4011
|
-
mode_source:
|
|
4012
|
-
base_url: safeBaseUrl(
|
|
5264
|
+
transport: "http",
|
|
5265
|
+
mode_source: `${status.api_key_source ?? status.api_key_tier}+${status.api_url_source ?? "default"}`,
|
|
5266
|
+
base_url: safeBaseUrl(status.v1_base_url),
|
|
4013
5267
|
local_db_path: localDbPath,
|
|
4014
5268
|
local_db_present: localDbPresent,
|
|
4015
5269
|
local_db_recordings: localDbRecordings,
|
|
@@ -4017,6 +5271,20 @@ function describeActiveStore(config, env = process.env) {
|
|
|
4017
5271
|
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
4018
5272
|
};
|
|
4019
5273
|
}
|
|
5274
|
+
function activeStoreFailClosed(description) {
|
|
5275
|
+
return description.transport === "none";
|
|
5276
|
+
}
|
|
5277
|
+
function describeActiveStoreLine(description, paint) {
|
|
5278
|
+
if (activeStoreFailClosed(description)) {
|
|
5279
|
+
const refusal = description.warning ? ` (${description.warning})` : "";
|
|
5280
|
+
const file = description.local_db_present ? `${description.local_db_path} is present but NOT opened` : `${description.local_db_path} absent`;
|
|
5281
|
+
return paint.fail(`\u2717 Active store: none \u2014 fail-closed${refusal}; ${file}`);
|
|
5282
|
+
}
|
|
5283
|
+
if (description.transport === "sqlite") {
|
|
5284
|
+
return paint.pass(`\u2713 Active store: sqlite \u2192 ${description.local_db_path} (selected by ${description.mode_source})`);
|
|
5285
|
+
}
|
|
5286
|
+
return paint.pass(`\u2713 Active store: http \u2192 ${description.base_url ?? "?"} (selected by ${description.mode_source})`);
|
|
5287
|
+
}
|
|
4020
5288
|
var PROBE_SEVERITY_GLYPHS = {
|
|
4021
5289
|
pass: "\u2713",
|
|
4022
5290
|
warn: "?",
|
|
@@ -4443,13 +5711,13 @@ ${block}` : block;
|
|
|
4443
5711
|
}
|
|
4444
5712
|
|
|
4445
5713
|
// src/cli/macos-permissions.ts
|
|
4446
|
-
import { spawnSync as
|
|
4447
|
-
import { existsSync as existsSync6, mkdtempSync, rmSync as rmSync3, statSync as
|
|
5714
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
5715
|
+
import { existsSync as existsSync6, mkdtempSync, rmSync as rmSync3, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
4448
5716
|
import { tmpdir as tmpdir2 } from "os";
|
|
4449
|
-
import { join as
|
|
4450
|
-
var defaultPermissionHelperRunner = (executable, arguments_, options) =>
|
|
5717
|
+
import { join as join8 } from "path";
|
|
5718
|
+
var defaultPermissionHelperRunner = (executable, arguments_, options) => spawnSync5(executable, arguments_, options);
|
|
4451
5719
|
function runMacOSPermissionRequest(appPath, runner = defaultPermissionHelperRunner) {
|
|
4452
|
-
const executable =
|
|
5720
|
+
const executable = join8(appPath, "Contents", "MacOS", "Recordings");
|
|
4453
5721
|
const result = runner(executable, ["--request-permissions", "--open-permission-settings"], { stdio: "inherit" });
|
|
4454
5722
|
return {
|
|
4455
5723
|
exitCode: result.error ? 1 : result.status ?? 1,
|
|
@@ -4460,8 +5728,8 @@ var CODESIGN_REQUIREMENT_SATISFIED_STATUS = 0;
|
|
|
4460
5728
|
var CODESIGN_REQUIREMENT_UNSATISFIED_STATUS = 3;
|
|
4461
5729
|
function tccDatabasePaths(home) {
|
|
4462
5730
|
return [
|
|
4463
|
-
|
|
4464
|
-
|
|
5731
|
+
join8(home, "Library", "Application Support", "com.apple.TCC", "TCC.db"),
|
|
5732
|
+
join8("/", "Library", "Application Support", "com.apple.TCC", "TCC.db")
|
|
4465
5733
|
];
|
|
4466
5734
|
}
|
|
4467
5735
|
function tccAuthValueLabel(value) {
|
|
@@ -4478,7 +5746,7 @@ function tccAuthValueLabel(value) {
|
|
|
4478
5746
|
return `unknown(${value})`;
|
|
4479
5747
|
}
|
|
4480
5748
|
}
|
|
4481
|
-
var defaultCodesignRequirementRunner = (requirementPath, appPath) =>
|
|
5749
|
+
var defaultCodesignRequirementRunner = (requirementPath, appPath) => spawnSync5("/usr/bin/codesign", ["--verify", "--verbose=2", "-R", requirementPath, appPath], { encoding: "utf8", stdio: ["ignore", "ignore", "ignore"] });
|
|
4482
5750
|
function verifyStoredRequirementWithCodesign(csreqHex, appPath, runner = defaultCodesignRequirementRunner, bundleExists = existsSync6) {
|
|
4483
5751
|
const normalized = csreqHex.trim();
|
|
4484
5752
|
if (normalized.length === 0 || normalized.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(normalized)) {
|
|
@@ -4488,8 +5756,8 @@ function verifyStoredRequirementWithCodesign(csreqHex, appPath, runner = default
|
|
|
4488
5756
|
return "unverifiable";
|
|
4489
5757
|
let scratchDirectory = null;
|
|
4490
5758
|
try {
|
|
4491
|
-
scratchDirectory = mkdtempSync(
|
|
4492
|
-
const requirementPath =
|
|
5759
|
+
scratchDirectory = mkdtempSync(join8(tmpdir2(), "recordings-tcc-csreq-"));
|
|
5760
|
+
const requirementPath = join8(scratchDirectory, "tcc-requirement.bin");
|
|
4493
5761
|
writeFileSync2(requirementPath, Buffer.from(normalized, "hex"));
|
|
4494
5762
|
const result = runner(requirementPath, appPath);
|
|
4495
5763
|
if (result.error)
|
|
@@ -4509,7 +5777,7 @@ function verifyStoredRequirementWithCodesign(csreqHex, appPath, runner = default
|
|
|
4509
5777
|
var defaultTccPermissionProbe = {
|
|
4510
5778
|
databasePresence: (dbPath) => {
|
|
4511
5779
|
try {
|
|
4512
|
-
|
|
5780
|
+
statSync2(dbPath);
|
|
4513
5781
|
return "present";
|
|
4514
5782
|
} catch (error) {
|
|
4515
5783
|
return error.code === "ENOENT" ? "absent" : "indeterminate";
|
|
@@ -4517,7 +5785,7 @@ var defaultTccPermissionProbe = {
|
|
|
4517
5785
|
},
|
|
4518
5786
|
readAccessRow: (dbPath, service) => {
|
|
4519
5787
|
const sql = "select ifnull(auth_value, '') || '|' || ifnull(hex(csreq), '') from access where service = '" + service.replace(/'/g, "''") + "' and client = '" + RECORDINGS_BUNDLE_IDENTIFIER + "' and client_type = 0 order by last_modified desc limit 1;";
|
|
4520
|
-
const result =
|
|
5788
|
+
const result = spawnSync5("/usr/bin/sqlite3", [dbPath, sql], {
|
|
4521
5789
|
encoding: "utf8",
|
|
4522
5790
|
stdio: ["ignore", "pipe", "pipe"]
|
|
4523
5791
|
});
|
|
@@ -4538,7 +5806,7 @@ var defaultTccPermissionProbe = {
|
|
|
4538
5806
|
verifyStoredRequirement: (csreqHex, appPath) => verifyStoredRequirementWithCodesign(csreqHex, appPath),
|
|
4539
5807
|
describeStoredRequirement: (csreqHex) => describeStoredRequirementWithCsreq(csreqHex)
|
|
4540
5808
|
};
|
|
4541
|
-
function describeStoredRequirementWithCsreq(csreqHex, runner = (requirementPath) =>
|
|
5809
|
+
function describeStoredRequirementWithCsreq(csreqHex, runner = (requirementPath) => spawnSync5("/usr/bin/csreq", ["-r", requirementPath, "-t"], {
|
|
4542
5810
|
encoding: "utf8",
|
|
4543
5811
|
stdio: ["ignore", "pipe", "ignore"]
|
|
4544
5812
|
})) {
|
|
@@ -4548,8 +5816,8 @@ function describeStoredRequirementWithCsreq(csreqHex, runner = (requirementPath)
|
|
|
4548
5816
|
}
|
|
4549
5817
|
let scratchDirectory = null;
|
|
4550
5818
|
try {
|
|
4551
|
-
scratchDirectory = mkdtempSync(
|
|
4552
|
-
const requirementPath =
|
|
5819
|
+
scratchDirectory = mkdtempSync(join8(tmpdir2(), "recordings-tcc-decode-"));
|
|
5820
|
+
const requirementPath = join8(scratchDirectory, "tcc-requirement.bin");
|
|
4553
5821
|
writeFileSync2(requirementPath, Buffer.from(normalized, "hex"));
|
|
4554
5822
|
const result = runner(requirementPath);
|
|
4555
5823
|
if (result.error || result.status !== 0)
|
|
@@ -4638,7 +5906,7 @@ function describeTccAuthorizationSubject(appPath) {
|
|
|
4638
5906
|
}
|
|
4639
5907
|
|
|
4640
5908
|
// src/cli/macos-shortcut.ts
|
|
4641
|
-
import { spawnSync as
|
|
5909
|
+
import { spawnSync as spawnSync6 } from "child_process";
|
|
4642
5910
|
import { existsSync as existsSync7 } from "fs";
|
|
4643
5911
|
var TRIGGER_DEFAULTS_EXECUTABLE = process.platform === "darwin" ? "/usr/bin/defaults" : process.env.RECORDINGS_TEST_DEFAULTS_EXECUTABLE ?? null;
|
|
4644
5912
|
var RECORDINGS_BUNDLE_ID = RECORDINGS_BUNDLE_IDENTIFIER;
|
|
@@ -4845,14 +6113,14 @@ var defaultBundleIdentifierReader = (bundlePath) => {
|
|
|
4845
6113
|
if (!existsSync7(`${bundlePath}/Contents/Info.plist`) && !existsSync7(`${bundlePath}/Contents/Info`)) {
|
|
4846
6114
|
return null;
|
|
4847
6115
|
}
|
|
4848
|
-
const result =
|
|
6116
|
+
const result = spawnSync6(TRIGGER_DEFAULTS_EXECUTABLE, ["read", `${bundlePath}/Contents/Info`, "CFBundleIdentifier"], { encoding: "utf8" });
|
|
4849
6117
|
if (result.status !== 0)
|
|
4850
6118
|
return null;
|
|
4851
6119
|
return result.stdout.trim() || null;
|
|
4852
6120
|
};
|
|
4853
6121
|
var PROCESS_LISTER_EXECUTABLE = process.platform === "darwin" ? "/bin/ps" : process.env.RECORDINGS_TEST_PS_EXECUTABLE ?? "/bin/ps";
|
|
4854
6122
|
var defaultProcessLister = () => {
|
|
4855
|
-
const result =
|
|
6123
|
+
const result = spawnSync6(PROCESS_LISTER_EXECUTABLE, ["-Awwo", "comm="], { encoding: "utf8" });
|
|
4856
6124
|
if (result.status !== 0)
|
|
4857
6125
|
return null;
|
|
4858
6126
|
return result.stdout;
|
|
@@ -4897,7 +6165,7 @@ function isThisApp(candidate, readBundleIdentifier) {
|
|
|
4897
6165
|
function readDefault(key) {
|
|
4898
6166
|
if (TRIGGER_DEFAULTS_EXECUTABLE === null)
|
|
4899
6167
|
return null;
|
|
4900
|
-
const result =
|
|
6168
|
+
const result = spawnSync6(TRIGGER_DEFAULTS_EXECUTABLE, ["read", RECORDINGS_BUNDLE_ID, key], {
|
|
4901
6169
|
encoding: "utf8"
|
|
4902
6170
|
});
|
|
4903
6171
|
if (result.status !== 0)
|
|
@@ -4908,7 +6176,7 @@ function writeDefault(key, value) {
|
|
|
4908
6176
|
if (TRIGGER_DEFAULTS_EXECUTABLE === null) {
|
|
4909
6177
|
throw new Error(`cannot write ${key}: this machine has no readable app UserDefaults domain`);
|
|
4910
6178
|
}
|
|
4911
|
-
const result =
|
|
6179
|
+
const result = spawnSync6(TRIGGER_DEFAULTS_EXECUTABLE, ["write", RECORDINGS_BUNDLE_ID, key, "-string", value], {
|
|
4912
6180
|
encoding: "utf8"
|
|
4913
6181
|
});
|
|
4914
6182
|
if (result.status !== 0) {
|
|
@@ -4944,14 +6212,14 @@ function writeUseFnKey(enabled) {
|
|
|
4944
6212
|
if (TRIGGER_DEFAULTS_EXECUTABLE === null) {
|
|
4945
6213
|
throw new Error(`cannot write ${USE_FN_KEY_DEFAULTS_KEY}: this machine has no readable app UserDefaults domain`);
|
|
4946
6214
|
}
|
|
4947
|
-
const result =
|
|
6215
|
+
const result = spawnSync6(TRIGGER_DEFAULTS_EXECUTABLE, ["write", RECORDINGS_BUNDLE_ID, USE_FN_KEY_DEFAULTS_KEY, "-bool", enabled ? "true" : "false"], { encoding: "utf8" });
|
|
4948
6216
|
if (result.status !== 0) {
|
|
4949
6217
|
throw new Error(`defaults write ${USE_FN_KEY_DEFAULTS_KEY} failed: ${result.stderr?.trim() || `exit ${result.status}`}`);
|
|
4950
6218
|
}
|
|
4951
6219
|
}
|
|
4952
6220
|
|
|
4953
6221
|
// src/cli/trigger-probe.ts
|
|
4954
|
-
import { closeSync, openSync, readSync, statSync as
|
|
6222
|
+
import { closeSync as closeSync3, openSync as openSync3, readSync, statSync as statSync3 } from "fs";
|
|
4955
6223
|
var FN_BLOCKING_ACCESSIBILITY_STATES = [
|
|
4956
6224
|
"denied",
|
|
4957
6225
|
"stale_allowed_for_previous_app_build"
|
|
@@ -5070,12 +6338,12 @@ function readAppLogTail(logPath, maxBytes = APP_LOG_TAIL_BYTES) {
|
|
|
5070
6338
|
return null;
|
|
5071
6339
|
let handle = null;
|
|
5072
6340
|
try {
|
|
5073
|
-
const size =
|
|
6341
|
+
const size = statSync3(logPath).size;
|
|
5074
6342
|
if (size === 0)
|
|
5075
6343
|
return null;
|
|
5076
6344
|
const length = Math.min(size, maxBytes);
|
|
5077
6345
|
const buffer = Buffer.allocUnsafe(length);
|
|
5078
|
-
handle =
|
|
6346
|
+
handle = openSync3(logPath, "r");
|
|
5079
6347
|
const read = readSync(handle, buffer, 0, length, size - length);
|
|
5080
6348
|
return buffer.subarray(0, read).toString("utf8");
|
|
5081
6349
|
} catch {
|
|
@@ -5083,7 +6351,7 @@ function readAppLogTail(logPath, maxBytes = APP_LOG_TAIL_BYTES) {
|
|
|
5083
6351
|
} finally {
|
|
5084
6352
|
if (handle !== null) {
|
|
5085
6353
|
try {
|
|
5086
|
-
|
|
6354
|
+
closeSync3(handle);
|
|
5087
6355
|
} catch {}
|
|
5088
6356
|
}
|
|
5089
6357
|
}
|
|
@@ -5190,10 +6458,10 @@ function currentMachineId(env = process.env, hostName = hostname()) {
|
|
|
5190
6458
|
}
|
|
5191
6459
|
|
|
5192
6460
|
// src/lib/bun-runtime.ts
|
|
5193
|
-
import { accessSync, constants as fsConstants, realpathSync as realpathSync3, statSync as
|
|
5194
|
-
import { isAbsolute as
|
|
6461
|
+
import { accessSync, constants as fsConstants, realpathSync as realpathSync3, statSync as statSync4 } from "fs";
|
|
6462
|
+
import { isAbsolute as isAbsolute4 } from "path";
|
|
5195
6463
|
import { randomBytes } from "crypto";
|
|
5196
|
-
import { spawnSync as
|
|
6464
|
+
import { spawnSync as spawnSync7 } from "child_process";
|
|
5197
6465
|
var BUN_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
5198
6466
|
var INSTALLER_ENVIRONMENT_KEYS = [
|
|
5199
6467
|
"HOME",
|
|
@@ -5207,19 +6475,19 @@ var INSTALLER_ENVIRONMENT_KEYS = [
|
|
|
5207
6475
|
];
|
|
5208
6476
|
var INSTALLER_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
|
|
5209
6477
|
function validateBunExecutable(candidate) {
|
|
5210
|
-
if (!
|
|
6478
|
+
if (!isAbsolute4(candidate))
|
|
5211
6479
|
return { reason: "path is not absolute" };
|
|
5212
6480
|
let executable;
|
|
5213
6481
|
try {
|
|
5214
6482
|
executable = realpathSync3(candidate);
|
|
5215
|
-
if (!
|
|
6483
|
+
if (!statSync4(executable).isFile())
|
|
5216
6484
|
return { reason: "resolved path is not a regular file" };
|
|
5217
6485
|
accessSync(executable, fsConstants.X_OK);
|
|
5218
6486
|
} catch {
|
|
5219
6487
|
return { reason: "path is missing, inaccessible, or not executable" };
|
|
5220
6488
|
}
|
|
5221
6489
|
const nonce = randomBytes(24).toString("hex");
|
|
5222
|
-
const probe =
|
|
6490
|
+
const probe = spawnSync7(executable, [
|
|
5223
6491
|
"-e",
|
|
5224
6492
|
`
|
|
5225
6493
|
import { realpathSync, statSync } from "node:fs";
|
|
@@ -5283,20 +6551,20 @@ function createInstallerEnvironment(environment, bunExecutable) {
|
|
|
5283
6551
|
}
|
|
5284
6552
|
|
|
5285
6553
|
// src/lib/release-install-policy.ts
|
|
5286
|
-
import { createHash as
|
|
6554
|
+
import { createHash as createHash3 } from "crypto";
|
|
5287
6555
|
import {
|
|
5288
6556
|
chmodSync as chmodSync2,
|
|
5289
|
-
closeSync as
|
|
6557
|
+
closeSync as closeSync4,
|
|
5290
6558
|
constants,
|
|
5291
|
-
fstatSync,
|
|
6559
|
+
fstatSync as fstatSync3,
|
|
5292
6560
|
fsyncSync,
|
|
5293
6561
|
mkdtempSync as mkdtempSync2,
|
|
5294
|
-
openSync as
|
|
5295
|
-
readFileSync as
|
|
6562
|
+
openSync as openSync4,
|
|
6563
|
+
readFileSync as readFileSync5,
|
|
5296
6564
|
rmSync as rmSync4,
|
|
5297
6565
|
writeFileSync as writeFileSync3
|
|
5298
6566
|
} from "fs";
|
|
5299
|
-
import { isAbsolute as
|
|
6567
|
+
import { isAbsolute as isAbsolute5, join as join9 } from "path";
|
|
5300
6568
|
var LOWER_SHA256 = /^[a-f0-9]{64}$/;
|
|
5301
6569
|
var LOWER_SOURCE_SHA = /^[a-f0-9]{40}$/;
|
|
5302
6570
|
var RELEASE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
|
|
@@ -5356,7 +6624,7 @@ function prepareReleaseInstallInputs(input) {
|
|
|
5356
6624
|
throw new Error("Team ID must be 10 uppercase alphanumeric characters");
|
|
5357
6625
|
}
|
|
5358
6626
|
const manifestBytes = readBoundedRegularFile(input.manifestPath, "manifest", MAX_MANIFEST_BYTES);
|
|
5359
|
-
const actualManifestSha256 =
|
|
6627
|
+
const actualManifestSha256 = createHash3("sha256").update(manifestBytes).digest("hex");
|
|
5360
6628
|
if (actualManifestSha256 !== input.manifestSha256) {
|
|
5361
6629
|
throw new Error("manifest does not match the operator-approved SHA-256");
|
|
5362
6630
|
}
|
|
@@ -5366,10 +6634,10 @@ function prepareReleaseInstallInputs(input) {
|
|
|
5366
6634
|
assertReleaseManifest(manifest, input);
|
|
5367
6635
|
assertReleaseEnvelope(envelope, manifest, manifestBytes.byteLength, input);
|
|
5368
6636
|
const snapshotRoot = input.snapshotRoot ?? "/private/tmp";
|
|
5369
|
-
if (!
|
|
6637
|
+
if (!isAbsolute5(snapshotRoot)) {
|
|
5370
6638
|
throw new Error("release snapshot root must be absolute");
|
|
5371
6639
|
}
|
|
5372
|
-
const snapshotDirectory = mkdtempSync2(
|
|
6640
|
+
const snapshotDirectory = mkdtempSync2(join9(snapshotRoot, "recordings-release-install."));
|
|
5373
6641
|
let cleaned = false;
|
|
5374
6642
|
const cleanup = () => {
|
|
5375
6643
|
if (cleaned)
|
|
@@ -5381,9 +6649,9 @@ function prepareReleaseInstallInputs(input) {
|
|
|
5381
6649
|
rmSync4(snapshotDirectory, { recursive: true, force: true });
|
|
5382
6650
|
};
|
|
5383
6651
|
try {
|
|
5384
|
-
const manifestSnapshot =
|
|
5385
|
-
const envelopeDigest =
|
|
5386
|
-
const envelopeSnapshot =
|
|
6652
|
+
const manifestSnapshot = join9(snapshotDirectory, `${input.manifestSha256}.manifest.json`);
|
|
6653
|
+
const envelopeDigest = createHash3("sha256").update(envelopeBytes).digest("hex");
|
|
6654
|
+
const envelopeSnapshot = join9(snapshotDirectory, `${envelopeDigest}.envelope.json`);
|
|
5387
6655
|
writeSnapshot(manifestSnapshot, manifestBytes);
|
|
5388
6656
|
writeSnapshot(envelopeSnapshot, envelopeBytes);
|
|
5389
6657
|
chmodSync2(snapshotDirectory, 320);
|
|
@@ -5394,19 +6662,19 @@ function prepareReleaseInstallInputs(input) {
|
|
|
5394
6662
|
}
|
|
5395
6663
|
}
|
|
5396
6664
|
function assertAbsolutePath(value, label) {
|
|
5397
|
-
if (!
|
|
6665
|
+
if (!isAbsolute5(value))
|
|
5398
6666
|
throw new Error(`${label} path must be absolute`);
|
|
5399
6667
|
}
|
|
5400
6668
|
function readBoundedRegularFile(path, label, maximum) {
|
|
5401
|
-
const descriptor =
|
|
6669
|
+
const descriptor = openSync4(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
5402
6670
|
try {
|
|
5403
|
-
const metadata =
|
|
6671
|
+
const metadata = fstatSync3(descriptor);
|
|
5404
6672
|
if (!metadata.isFile() || metadata.size < 1 || metadata.size > maximum) {
|
|
5405
6673
|
throw new Error(`${label} must be a non-empty bounded regular file`);
|
|
5406
6674
|
}
|
|
5407
|
-
return
|
|
6675
|
+
return readFileSync5(descriptor);
|
|
5408
6676
|
} finally {
|
|
5409
|
-
|
|
6677
|
+
closeSync4(descriptor);
|
|
5410
6678
|
}
|
|
5411
6679
|
}
|
|
5412
6680
|
function parseObject(bytes, label) {
|
|
@@ -5486,22 +6754,22 @@ function isObject(value) {
|
|
|
5486
6754
|
}
|
|
5487
6755
|
function writeSnapshot(path, bytes) {
|
|
5488
6756
|
writeFileSync3(path, bytes, { flag: "wx", mode: 256 });
|
|
5489
|
-
const descriptor =
|
|
6757
|
+
const descriptor = openSync4(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
5490
6758
|
try {
|
|
5491
6759
|
fsyncSync(descriptor);
|
|
5492
6760
|
} finally {
|
|
5493
|
-
|
|
6761
|
+
closeSync4(descriptor);
|
|
5494
6762
|
}
|
|
5495
6763
|
}
|
|
5496
6764
|
|
|
5497
6765
|
// src/cli/desktop-snapshot.ts
|
|
5498
|
-
import { chmodSync as chmodSync3, mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync3, renameSync as renameSync2, rmSync as rmSync5, statSync as
|
|
5499
|
-
import { dirname as dirname4, join as
|
|
5500
|
-
import { spawnSync as
|
|
6766
|
+
import { chmodSync as chmodSync3, mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync3, renameSync as renameSync2, rmSync as rmSync5, statSync as statSync5 } from "fs";
|
|
6767
|
+
import { dirname as dirname4, join as join10, resolve as resolve3 } from "path";
|
|
6768
|
+
import { spawnSync as spawnSync8 } from "child_process";
|
|
5501
6769
|
var SCREEN_CAPTURE_EXECUTABLE = "/usr/sbin/screencapture";
|
|
5502
6770
|
var DEFAULT_DESKTOP_SNAPSHOT = "desktop-snapshot.png";
|
|
5503
6771
|
function runScreenCapture(executable, arguments_) {
|
|
5504
|
-
const result =
|
|
6772
|
+
const result = spawnSync8(executable, arguments_, {
|
|
5505
6773
|
encoding: "utf8",
|
|
5506
6774
|
stdio: ["ignore", "ignore", "pipe"]
|
|
5507
6775
|
});
|
|
@@ -5518,9 +6786,9 @@ function exportDesktopSnapshot(output = DEFAULT_DESKTOP_SNAPSHOT, dependencies =
|
|
|
5518
6786
|
const destination = resolve3(dependencies.cwd ?? process.cwd(), output);
|
|
5519
6787
|
const destinationDirectory = dirname4(destination);
|
|
5520
6788
|
mkdirSync4(destinationDirectory, { recursive: true });
|
|
5521
|
-
const stagingDirectory = mkdtempSync3(
|
|
6789
|
+
const stagingDirectory = mkdtempSync3(join10(destinationDirectory, ".recordings-desktop-"));
|
|
5522
6790
|
chmodSync3(stagingDirectory, 448);
|
|
5523
|
-
const stagingPath =
|
|
6791
|
+
const stagingPath = join10(stagingDirectory, "snapshot.png");
|
|
5524
6792
|
try {
|
|
5525
6793
|
const capture = dependencies.capture ?? runScreenCapture;
|
|
5526
6794
|
const result = capture(SCREEN_CAPTURE_EXECUTABLE, [
|
|
@@ -5540,7 +6808,7 @@ function exportDesktopSnapshot(output = DEFAULT_DESKTOP_SNAPSHOT, dependencies =
|
|
|
5540
6808
|
}
|
|
5541
6809
|
let size = 0;
|
|
5542
6810
|
try {
|
|
5543
|
-
const snapshot =
|
|
6811
|
+
const snapshot = statSync5(stagingPath);
|
|
5544
6812
|
if (snapshot.isFile())
|
|
5545
6813
|
size = snapshot.size;
|
|
5546
6814
|
} catch {}
|
|
@@ -5643,7 +6911,7 @@ Output:`));
|
|
|
5643
6911
|
})
|
|
5644
6912
|
});
|
|
5645
6913
|
if (parentOpts.json) {
|
|
5646
|
-
|
|
6914
|
+
await printJSON(recording);
|
|
5647
6915
|
} else {
|
|
5648
6916
|
console.log(chalk.dim(`
|
|
5649
6917
|
Saved as ${recording.id.slice(0, 8)}`));
|
|
@@ -5695,7 +6963,7 @@ program.command("transcribe <file>").description("Transcribe an existing audio f
|
|
|
5695
6963
|
})
|
|
5696
6964
|
}, recordingId);
|
|
5697
6965
|
if (parentOpts.json) {
|
|
5698
|
-
|
|
6966
|
+
await printJSON(recording);
|
|
5699
6967
|
} else if (processed.mode === "enhanced") {
|
|
5700
6968
|
console.log(chalk.green("Enhanced:"));
|
|
5701
6969
|
console.log(processed.text);
|
|
@@ -5750,7 +7018,7 @@ program.command("save-text [text]").description("Save already-transcribed text a
|
|
|
5750
7018
|
metadata
|
|
5751
7019
|
}, recordingId);
|
|
5752
7020
|
if (parentOpts.json) {
|
|
5753
|
-
|
|
7021
|
+
await printJSON(recording);
|
|
5754
7022
|
} else if (processed.mode === "enhanced") {
|
|
5755
7023
|
console.log(processed.text);
|
|
5756
7024
|
} else {
|
|
@@ -5772,11 +7040,11 @@ ${text}`;
|
|
|
5772
7040
|
try {
|
|
5773
7041
|
const result = await enhanceText(text, instruction, config);
|
|
5774
7042
|
if (parentOpts.json) {
|
|
5775
|
-
|
|
7043
|
+
await printJSON({
|
|
5776
7044
|
raw_text: text,
|
|
5777
7045
|
processed_text: result.enhanced,
|
|
5778
7046
|
model_used: result.model
|
|
5779
|
-
}
|
|
7047
|
+
});
|
|
5780
7048
|
} else {
|
|
5781
7049
|
console.log(result.enhanced);
|
|
5782
7050
|
}
|
|
@@ -5815,7 +7083,7 @@ program.command("save <text>").description("Save raw text as a recording (no aud
|
|
|
5815
7083
|
machine_id: currentMachineId()
|
|
5816
7084
|
});
|
|
5817
7085
|
if (parentOpts.json) {
|
|
5818
|
-
|
|
7086
|
+
await printJSON(recording);
|
|
5819
7087
|
} else {
|
|
5820
7088
|
console.log(chalk.green(`\u2713 Saved recording ${recording.id}`));
|
|
5821
7089
|
}
|
|
@@ -5842,7 +7110,7 @@ program.command("list").description("List recordings in compact form").option("-
|
|
|
5842
7110
|
const store = getStore();
|
|
5843
7111
|
const recordings = await store.listRecordings(filter);
|
|
5844
7112
|
if (parentOpts.json) {
|
|
5845
|
-
|
|
7113
|
+
await printJSON(recordings);
|
|
5846
7114
|
return;
|
|
5847
7115
|
}
|
|
5848
7116
|
const total = await countStoreRecordings(store, withoutPagination(filter));
|
|
@@ -5874,7 +7142,7 @@ program.command("search <query>").description("Search recordings by text content
|
|
|
5874
7142
|
const store = getStore();
|
|
5875
7143
|
const results = await store.searchRecordings(query, filter);
|
|
5876
7144
|
if (parentOpts.json) {
|
|
5877
|
-
|
|
7145
|
+
await printJSON(results);
|
|
5878
7146
|
return;
|
|
5879
7147
|
}
|
|
5880
7148
|
const total = await countStoreRecordings(store, withoutPagination({ ...filter, search: query }));
|
|
@@ -5900,7 +7168,7 @@ program.command("stats").description("Show recording statistics").action(async (
|
|
|
5900
7168
|
const parentOpts = program.opts();
|
|
5901
7169
|
const stats = await getStore().getRecordingStats();
|
|
5902
7170
|
if (parentOpts.json) {
|
|
5903
|
-
|
|
7171
|
+
await printJSON(stats);
|
|
5904
7172
|
return;
|
|
5905
7173
|
}
|
|
5906
7174
|
console.log(chalk.bold(`Recording Statistics
|
|
@@ -5926,7 +7194,7 @@ program.command("agents").description("List registered agents").option("-n, --li
|
|
|
5926
7194
|
const agents = await getStore().listAgents();
|
|
5927
7195
|
const page = parentOpts.json ? maybePageJson(agents, pagination, opts) : pageItems(agents, pagination);
|
|
5928
7196
|
if (parentOpts.json) {
|
|
5929
|
-
|
|
7197
|
+
await printJSON(page);
|
|
5930
7198
|
return;
|
|
5931
7199
|
}
|
|
5932
7200
|
if (page.length === 0) {
|
|
@@ -5953,7 +7221,7 @@ projectCommand.command("register").description("Register a project in the active
|
|
|
5953
7221
|
const parentOpts = program.opts();
|
|
5954
7222
|
const project = await getStore().registerProject(opts.name, opts.path, opts.description);
|
|
5955
7223
|
if (parentOpts.json) {
|
|
5956
|
-
|
|
7224
|
+
await printJSON(project);
|
|
5957
7225
|
return;
|
|
5958
7226
|
}
|
|
5959
7227
|
console.log(`${chalk.cyan(truncateText(project.id, 80))} ${chalk.bold(truncateText(project.name, 80))} \u2014 ${truncatePath(project.path, 120)}`);
|
|
@@ -5964,7 +7232,7 @@ program.command("projects").description("List registered projects").option("-n,
|
|
|
5964
7232
|
const projects = await getStore().listProjects();
|
|
5965
7233
|
const page = parentOpts.json ? maybePageJson(projects, pagination, opts) : pageItems(projects, pagination);
|
|
5966
7234
|
if (parentOpts.json) {
|
|
5967
|
-
|
|
7235
|
+
await printJSON(page);
|
|
5968
7236
|
return;
|
|
5969
7237
|
}
|
|
5970
7238
|
if (page.length === 0) {
|
|
@@ -5989,10 +7257,10 @@ program.command("projects").description("List registered projects").option("-n,
|
|
|
5989
7257
|
});
|
|
5990
7258
|
program.command("init").description("Initialize .recordings/ in current directory").action(() => {
|
|
5991
7259
|
const { mkdirSync: mkdirSync5, writeFileSync: writeFileSync4, existsSync: existsSync9 } = __require("fs");
|
|
5992
|
-
const { join:
|
|
5993
|
-
const dir =
|
|
5994
|
-
const audioDir =
|
|
5995
|
-
const configFile =
|
|
7260
|
+
const { join: join11 } = __require("path");
|
|
7261
|
+
const dir = join11(process.cwd(), ".recordings");
|
|
7262
|
+
const audioDir = join11(dir, "audio");
|
|
7263
|
+
const configFile = join11(dir, "config.json");
|
|
5996
7264
|
mkdirSync5(audioDir, { recursive: true });
|
|
5997
7265
|
if (!existsSync9(configFile)) {
|
|
5998
7266
|
const defaultConf = {
|
|
@@ -6015,9 +7283,9 @@ program.command("init").description("Initialize .recordings/ in current director
|
|
|
6015
7283
|
console.log(chalk.dim(" db: .recordings/recordings.db"));
|
|
6016
7284
|
});
|
|
6017
7285
|
var appCommand = program.command("app").description("Manage the macOS app installed from this package");
|
|
6018
|
-
appCommand.command("install").description("Install a release or explicitly approved local-only
|
|
7286
|
+
appCommand.command("install").description("Install a release or explicitly approved local-only Hasna Recordings.app artifact").requiredOption("--artifact <path>", "Finalized Hasna Recordings.app ZIP artifact").requiredOption("--manifest <path>", "Artifact provenance manifest").option("--envelope <path>", "Signed release envelope (required for release artifacts)").option("--expected-team-id <team>", "Required Developer ID TeamIdentifier for release artifacts").requiredOption("--manifest-sha256 <sha256>", "Authenticated release-manifest SHA-256").requiredOption("--expected-source-sha <sha>", "Exact approved 40-character source commit").requiredOption("--expected-version <version>", "Exact approved release version").option("--expected-hostname <hostname>", "Exact deployment hostname to verify before any install mutation").option("--artifact-policy <policy>", "Artifact policy: release or local-only", "release").option("--approved-target <station>", "Exact approved target; fleet for release artifacts", "fleet").option("--variant <variant>", "Artifact variant the operator intends to install: full or bar (default: full)", "full").option("--approved-target-identity-kind <kind>", "Target identity kind: hardware_uuid_sha256 or tailscale_node_id_sha256").option("--approved-target-identity-sha256 <sha256>", "Authenticated SHA-256 of the approved target identity; none for release artifacts", "none").option("--acknowledge-local-signing-and-permissions", "Acknowledge local-only ad-hoc identity and possible permission reauthorization").option("--expected-old-identity-sha256 <sha256>", "Exact installed identity approved for migration").option("--expected-new-identity-sha256 <sha256>", "Exact candidate identity approved for migration").option("--allow-signing-identity-migration", "Allow one reviewed signer change that requires new macOS permission approval").option("--allow-adhoc-identity-migration", "Accept that replacing an ad-hoc signed local-only app voids its Microphone and Accessibility grants").option("--launch", "Launch and verify the canonical app after installation").option("--launch-timeout <seconds>", "Canonical process launch timeout").action((opts) => {
|
|
6019
7287
|
if (process.platform !== "darwin") {
|
|
6020
|
-
console.error(chalk.red("
|
|
7288
|
+
console.error(chalk.red("Hasna Recordings.app installation is only supported on macOS"));
|
|
6021
7289
|
process.exit(1);
|
|
6022
7290
|
}
|
|
6023
7291
|
if (opts.variant !== "full" && opts.variant !== "bar") {
|
|
@@ -6037,7 +7305,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
6037
7305
|
try {
|
|
6038
7306
|
assertReleaseOnlyOptions(opts);
|
|
6039
7307
|
if (opts.expectedHostname) {
|
|
6040
|
-
const hostnameResult =
|
|
7308
|
+
const hostnameResult = spawnSync9("/bin/hostname", ["-s"], {
|
|
6041
7309
|
encoding: "utf8",
|
|
6042
7310
|
stdio: ["ignore", "pipe", "ignore"],
|
|
6043
7311
|
env: {
|
|
@@ -6066,7 +7334,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
6066
7334
|
process.exit(1);
|
|
6067
7335
|
}
|
|
6068
7336
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
6069
|
-
const canonicalAppPath = pathJoin(home, "Applications", "
|
|
7337
|
+
const canonicalAppPath = pathJoin(home, "Applications", "Hasna Recordings.app");
|
|
6070
7338
|
const legacyInstallPaths = findLegacyMacOSAppPaths(home, canonicalAppPath);
|
|
6071
7339
|
const installedAppPath = resolveInstalledAppPath(home, canonicalAppPath, legacyInstallPaths);
|
|
6072
7340
|
const updateClientPath = pathJoin(installedAppPath, "Contents", "Helpers", "recordings-update-client");
|
|
@@ -6077,7 +7345,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
6077
7345
|
}
|
|
6078
7346
|
const result2 = (() => {
|
|
6079
7347
|
try {
|
|
6080
|
-
return
|
|
7348
|
+
return spawnSync9(updateClientPath, [
|
|
6081
7349
|
"install",
|
|
6082
7350
|
"--artifact",
|
|
6083
7351
|
opts.artifact,
|
|
@@ -6176,7 +7444,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
6176
7444
|
if (opts.launch)
|
|
6177
7445
|
installerArgs.push("--launch");
|
|
6178
7446
|
const installerEnvironment = createInstallerEnvironment(process.env, bunExecutable);
|
|
6179
|
-
const result =
|
|
7447
|
+
const result = spawnSync9("/bin/bash", installerArgs, {
|
|
6180
7448
|
stdio: "inherit",
|
|
6181
7449
|
env: installerEnvironment
|
|
6182
7450
|
});
|
|
@@ -6186,7 +7454,7 @@ appCommand.command("install").description("Install a release or explicitly appro
|
|
|
6186
7454
|
}
|
|
6187
7455
|
process.exit(result.status ?? 1);
|
|
6188
7456
|
});
|
|
6189
|
-
appCommand.command("status").description("Show installed
|
|
7457
|
+
appCommand.command("status").description("Show installed Hasna Recordings.app status").option("--verbose", "Show package paths, code hash, and log path").action((opts) => {
|
|
6190
7458
|
const status = getMacOSAppStatus();
|
|
6191
7459
|
const trigger = probeTriggerDiagnostics({
|
|
6192
7460
|
accessibilityPermission: process.platform === "darwin" ? status.accessibility_permission : null,
|
|
@@ -6226,7 +7494,7 @@ appCommand.command("status").description("Show installed HasnaRecordings.app sta
|
|
|
6226
7494
|
console.log(chalk.dim("Use --verbose for paths/code hash/log, or --json for the full status object."));
|
|
6227
7495
|
}
|
|
6228
7496
|
});
|
|
6229
|
-
appCommand.command("permissions").description("Show macOS permission state for
|
|
7497
|
+
appCommand.command("permissions").description("Show macOS permission state for Hasna Recordings.app").action(() => {
|
|
6230
7498
|
const status = getMacOSAppStatus();
|
|
6231
7499
|
const permissions = {
|
|
6232
7500
|
platform: status.platform,
|
|
@@ -6234,7 +7502,7 @@ appCommand.command("permissions").description("Show macOS permission state for H
|
|
|
6234
7502
|
installed_app_path: status.installed_app_path,
|
|
6235
7503
|
installed: status.installed,
|
|
6236
7504
|
legacy_install_paths: status.legacy_install_paths,
|
|
6237
|
-
permission_subject: describeTccAuthorizationSubject(status.installed_app_path),
|
|
7505
|
+
permission_subject: describeTccAuthorizationSubject(status.installed ? status.installed_app_path : null),
|
|
6238
7506
|
microphone: status.microphone_permission,
|
|
6239
7507
|
accessibility: status.accessibility_permission,
|
|
6240
7508
|
app_code_hash: status.app_code_hash,
|
|
@@ -6262,21 +7530,21 @@ appCommand.command("permissions").description("Show macOS permission state for H
|
|
|
6262
7530
|
}
|
|
6263
7531
|
console.log(`Log: ${permissions.log_path}`);
|
|
6264
7532
|
});
|
|
6265
|
-
appCommand.command("reset-permissions").description("Reset macOS Microphone and Accessibility permissions for
|
|
7533
|
+
appCommand.command("reset-permissions").description("Reset macOS Microphone and Accessibility permissions for Hasna Recordings.app").action(() => {
|
|
6266
7534
|
if (process.platform !== "darwin") {
|
|
6267
7535
|
console.error(chalk.red("Permission reset is only available on macOS"));
|
|
6268
7536
|
process.exit(1);
|
|
6269
7537
|
}
|
|
6270
7538
|
resetMacOSPermissions();
|
|
6271
7539
|
});
|
|
6272
|
-
appCommand.command("request-permissions").description("Open
|
|
7540
|
+
appCommand.command("request-permissions").description("Open Hasna Recordings.app and trigger macOS Microphone and Accessibility permission prompts").option("--reset", "Reset existing Microphone and Accessibility decisions before requesting").action((opts) => {
|
|
6273
7541
|
if (process.platform !== "darwin") {
|
|
6274
7542
|
console.error(chalk.red("Permission prompts are only available on macOS"));
|
|
6275
7543
|
process.exit(1);
|
|
6276
7544
|
}
|
|
6277
7545
|
const status = getMacOSAppStatus();
|
|
6278
7546
|
if (!status.installed) {
|
|
6279
|
-
console.error(chalk.red("
|
|
7547
|
+
console.error(chalk.red("Hasna Recordings.app is not installed. Run: recordings app install"));
|
|
6280
7548
|
process.exit(1);
|
|
6281
7549
|
}
|
|
6282
7550
|
if (opts.reset) {
|
|
@@ -6288,14 +7556,14 @@ appCommand.command("request-permissions").description("Open HasnaRecordings.app
|
|
|
6288
7556
|
}
|
|
6289
7557
|
process.exit(result.exitCode);
|
|
6290
7558
|
});
|
|
6291
|
-
appCommand.command("log").description("Show the
|
|
7559
|
+
appCommand.command("log").description("Show the Hasna Recordings.app diagnostic log").option("-n, --lines <lines>", "Number of lines to print", String(DEFAULT_LOG_LINES)).action((opts) => {
|
|
6292
7560
|
const status = getMacOSAppStatus();
|
|
6293
7561
|
if (!existsSync8(status.log_path)) {
|
|
6294
7562
|
console.log("");
|
|
6295
7563
|
return;
|
|
6296
7564
|
}
|
|
6297
7565
|
const lines = Math.max(1, parseInt(opts.lines, 10) || DEFAULT_LOG_LINES);
|
|
6298
|
-
const result =
|
|
7566
|
+
const result = spawnSync9("tail", ["-n", String(lines), status.log_path], {
|
|
6299
7567
|
encoding: "utf8"
|
|
6300
7568
|
});
|
|
6301
7569
|
if (result.error) {
|
|
@@ -6314,17 +7582,17 @@ appCommand.command("snapshot [output]").description("Write the current main desk
|
|
|
6314
7582
|
console.log(chalk.green(`Desktop snapshot written: ${path}`));
|
|
6315
7583
|
}
|
|
6316
7584
|
});
|
|
6317
|
-
appCommand.command("open").description("Open the installed
|
|
7585
|
+
appCommand.command("open").description("Open the installed Hasna Recordings.app").action(() => {
|
|
6318
7586
|
const status = getMacOSAppStatus();
|
|
6319
7587
|
if (process.platform !== "darwin") {
|
|
6320
|
-
console.error(chalk.red("
|
|
7588
|
+
console.error(chalk.red("Hasna Recordings.app can only be opened on macOS"));
|
|
6321
7589
|
process.exit(1);
|
|
6322
7590
|
}
|
|
6323
7591
|
if (!status.installed) {
|
|
6324
|
-
console.error(chalk.red("
|
|
7592
|
+
console.error(chalk.red("Hasna Recordings.app is not installed. Run: recordings app install"));
|
|
6325
7593
|
process.exit(1);
|
|
6326
7594
|
}
|
|
6327
|
-
const result =
|
|
7595
|
+
const result = spawnSync9("open", [status.installed_app_path], { stdio: "inherit" });
|
|
6328
7596
|
if (result.error) {
|
|
6329
7597
|
console.error(chalk.red(result.error.message));
|
|
6330
7598
|
process.exit(1);
|
|
@@ -6410,8 +7678,9 @@ program.command("check").description("Check system dependencies (sox, API keys)
|
|
|
6410
7678
|
persistence_probe: persistence,
|
|
6411
7679
|
trigger
|
|
6412
7680
|
}, null, 2));
|
|
6413
|
-
if (probeFailed || triggerFailed)
|
|
7681
|
+
if (probeFailed || triggerFailed || activeStoreFailClosed(activeStore)) {
|
|
6414
7682
|
process.exitCode = 1;
|
|
7683
|
+
}
|
|
6415
7684
|
return;
|
|
6416
7685
|
}
|
|
6417
7686
|
if (deps.available) {
|
|
@@ -6422,14 +7691,18 @@ program.command("check").description("Check system dependencies (sox, API keys)
|
|
|
6422
7691
|
if (config.openai_api_key) {
|
|
6423
7692
|
console.log(chalk.green(`\u2713 OpenAI API key present`) + chalk.dim(opts.probe ? "" : " (presence only \u2014 run 'recordings check --probe' to verify it is accepted)"));
|
|
6424
7693
|
} else {
|
|
6425
|
-
console.log(chalk.red(`\u2717 OpenAI API key not found. Set OPENAI_API_KEY
|
|
7694
|
+
console.log(chalk.red(`\u2717 OpenAI API key not found. Set OPENAI_API_KEY (e.g. ` + `'secrets exec <key> --as OPENAI_API_KEY -- recordings \u2026')`));
|
|
6426
7695
|
}
|
|
6427
7696
|
if (enhKey) {
|
|
6428
7697
|
console.log(chalk.green(`\u2713 Enhancement API key present (model: ${transcriberModel})`) + chalk.dim(opts.probe ? "" : " (presence only \u2014 'recordings check --probe' verifies it is accepted)"));
|
|
6429
7698
|
} else {
|
|
6430
7699
|
console.log(chalk.yellow(`\u26A0 Enhancement API key not configured \u2014 enhancement disabled`));
|
|
6431
7700
|
}
|
|
6432
|
-
console.log(
|
|
7701
|
+
console.log(describeActiveStoreLine(activeStore, {
|
|
7702
|
+
pass: (text) => chalk.green(text),
|
|
7703
|
+
warn: (text) => chalk.yellow(text),
|
|
7704
|
+
fail: (text) => chalk.red(text)
|
|
7705
|
+
}));
|
|
6433
7706
|
if (activeStore.divergent) {
|
|
6434
7707
|
console.log(chalk.yellow("\u26A0") + ` Two datasets present: ${activeStore.local_db_recordings} recordings sit in ` + `${activeStore.local_db_path}, which is NOT the live store.`);
|
|
6435
7708
|
}
|
|
@@ -6453,7 +7726,7 @@ program.command("check").description("Check system dependencies (sox, API keys)
|
|
|
6453
7726
|
console.log(chalk.dim(" Could not read the TCC database. This is NOT a denial and NOT proof the app never " + "asked. Reading it needs Full Disk Access, which is held by the session's " + "RESPONSIBLE process and inherited by its children \u2014 not granted per-tool: on " + "a fleet Mac `bun` is explicitly denied and still reads the database over SSH, " + "because it inherits sshd's grant. So re-run from a plain ssh shell rather than " + "granting Full Disk Access to tmux or bun, and not under sudo, which changes the " + "responsible process. A missing sqlite3, a locked database or a corrupt file " + "produce this same state, so the cause is not established either."));
|
|
6454
7727
|
}
|
|
6455
7728
|
if (!micOk && !micUnreadable) {
|
|
6456
|
-
console.log(chalk.dim("
|
|
7729
|
+
console.log(chalk.dim(" Hasna Recordings.app cannot capture audio without this. macOS does not error when it is " + "missing \u2014 it delivers silent audio. This grant CANNOT be set remotely; it needs a " + "human at the keyboard:"));
|
|
6457
7730
|
const instruction = microphoneGrantInstruction({
|
|
6458
7731
|
installedAppPath: macStatus.installed_app_path,
|
|
6459
7732
|
otherAppPaths: macStatus.legacy_install_paths,
|
|
@@ -6486,7 +7759,7 @@ program.command("check").description("Check system dependencies (sox, API keys)
|
|
|
6486
7759
|
});
|
|
6487
7760
|
console.log(persistenceMarker + ` Persistence round-trip: ${persistence.message}`);
|
|
6488
7761
|
}
|
|
6489
|
-
if (probeFailed || triggerFailed)
|
|
7762
|
+
if (probeFailed || triggerFailed || activeStoreFailClosed(activeStore))
|
|
6490
7763
|
process.exitCode = 1;
|
|
6491
7764
|
});
|
|
6492
7765
|
function reportTriggerDiagnosis(trigger) {
|
|
@@ -6612,7 +7885,7 @@ Bye.`));
|
|
|
6612
7885
|
}
|
|
6613
7886
|
});
|
|
6614
7887
|
});
|
|
6615
|
-
program.command("shortcut").description("Show or change the global recording trigger (macOS). Exits non-zero when a change was " + "written while
|
|
7888
|
+
program.command("shortcut").description("Show or change the global recording trigger (macOS). Exits non-zero when a change was " + "written while Hasna Recordings.app was running, because the running instance keeps the " + "trigger it registered with \u2014 the write is stored but not armed until it is reopened.").option("--set <chord>", 'Set the app hotkey, e.g. "f13" or "ctrl+opt+r"').option("--reset", "Reset the app hotkey to the app's built-in default").option("--fn <state>", "Use fn/Globe as push-to-talk: on|off").option("--keys", "List the key names accepted by --set").option("--script", "Write the app-less toggle script and print its path").option("--raycast", "Generate a Raycast script command (requires Raycast)").option("--karabiner", "Generate a Karabiner-Elements rule (requires Karabiner-Elements)").option("--skhd", "Print an skhd hotkey config (requires skhd)").option("--hammerspoon", "Print a Hammerspoon config (requires Hammerspoon)").action((opts) => {
|
|
6616
7889
|
const { writeFileSync: writeFileSync4, mkdirSync: mkdirSync5, chmodSync: chmodSync4, existsSync: existsSync9 } = __require("fs");
|
|
6617
7890
|
const { join: pathJoin2 } = __require("path");
|
|
6618
7891
|
const { homedir: getHome } = __require("os");
|
|
@@ -6726,10 +7999,10 @@ program.command("shortcut").description("Show or change the global recording tri
|
|
|
6726
7999
|
function reportPickup() {
|
|
6727
8000
|
const pickup = describeTriggerPickup(runningBundles());
|
|
6728
8001
|
if (pickup.armed) {
|
|
6729
|
-
console.log(chalk.dim("
|
|
8002
|
+
console.log(chalk.dim(" Hasna Recordings.app is not running; it will register this on next launch."));
|
|
6730
8003
|
return;
|
|
6731
8004
|
}
|
|
6732
|
-
console.log(chalk.yellow("
|
|
8005
|
+
console.log(chalk.yellow(" Hasna Recordings.app is running and still holds the previous trigger.") + `
|
|
6733
8006
|
Quit and reopen it to arm this one:`);
|
|
6734
8007
|
for (const path of pickup.runningBundlePaths)
|
|
6735
8008
|
console.log(chalk.dim(` ${path}`));
|
|
@@ -6788,7 +8061,7 @@ program.command("shortcut").description("Show or change the global recording tri
|
|
|
6788
8061
|
` + ` ${fnGrant.settingsPath} > enable Recordings`);
|
|
6789
8062
|
const target = grantTargetPaths();
|
|
6790
8063
|
if (target.paths.length === 0) {
|
|
6791
|
-
console.log(chalk.dim(" (no installed
|
|
8064
|
+
console.log(chalk.dim(" (no installed Hasna Recordings.app found to grant it to)"));
|
|
6792
8065
|
}
|
|
6793
8066
|
for (const path of target.paths) {
|
|
6794
8067
|
console.log(chalk.dim(` grant it to: ${path}${target.running ? "" : " (not running \u2014 installed copy)"}`));
|
|
@@ -6815,7 +8088,7 @@ program.command("shortcut").description("Show or change the global recording tri
|
|
|
6815
8088
|
# Toggle recording on/off. Run this from a global hotkey.
|
|
6816
8089
|
# Each press toggles: start recording -> stop + transcribe + copy to clipboard
|
|
6817
8090
|
#
|
|
6818
|
-
# This is the app-less path. If
|
|
8091
|
+
# This is the app-less path. If Hasna Recordings.app is running, prefer its own hotkey
|
|
6819
8092
|
# ("recordings shortcut --set ..."): the app streams transcription and pastes into
|
|
6820
8093
|
# the focused field, which this script cannot do.
|
|
6821
8094
|
set -e
|
|
@@ -7012,7 +8285,7 @@ async function readSaveTextInput(text, opts) {
|
|
|
7012
8285
|
}
|
|
7013
8286
|
let rawText;
|
|
7014
8287
|
if (opts.textFile !== undefined) {
|
|
7015
|
-
rawText =
|
|
8288
|
+
rawText = readFileSync6(opts.textFile, "utf8");
|
|
7016
8289
|
} else if (opts.stdin) {
|
|
7017
8290
|
rawText = await Bun.stdin.text();
|
|
7018
8291
|
} else {
|
|
@@ -7106,7 +8379,7 @@ async function printRecordingDetail(id) {
|
|
|
7106
8379
|
return;
|
|
7107
8380
|
}
|
|
7108
8381
|
if (parentOpts.json) {
|
|
7109
|
-
|
|
8382
|
+
await printJSON(recording);
|
|
7110
8383
|
return;
|
|
7111
8384
|
}
|
|
7112
8385
|
console.log(formatRecordingDetail(recording));
|
|
@@ -7226,7 +8499,7 @@ function relativeHint(value) {
|
|
|
7226
8499
|
return `${Math.floor(hours / 24)}d ago`;
|
|
7227
8500
|
}
|
|
7228
8501
|
program.command("mcp").description("Install recordings MCP server into Claude Code, Codex, or Gemini").option("--claude", "Install into Claude Code (via `claude mcp add`)").option("--codex", "Install into Codex (~/.codex/config.toml)").option("--gemini", "Install into Gemini (~/.gemini/settings.json)").option("--all", "Install into all supported agents").option("--uninstall", "Remove recordings MCP from config").action(async (opts) => {
|
|
7229
|
-
const { readFileSync:
|
|
8502
|
+
const { readFileSync: readFileSync7, writeFileSync: writeFileSync4, existsSync: fileExists } = __require("fs");
|
|
7230
8503
|
const { join: pathJoin2 } = __require("path");
|
|
7231
8504
|
const { homedir: getHome } = __require("os");
|
|
7232
8505
|
const { execSync } = __require("child_process");
|
|
@@ -7259,7 +8532,7 @@ program.command("mcp").description("Install recordings MCP server into Claude Co
|
|
|
7259
8532
|
if (target === "codex") {
|
|
7260
8533
|
const configPath = pathJoin2(home, ".codex", "config.toml");
|
|
7261
8534
|
if (fileExists(configPath)) {
|
|
7262
|
-
const content =
|
|
8535
|
+
const content = readFileSync7(configPath, "utf-8");
|
|
7263
8536
|
if (opts.uninstall) {
|
|
7264
8537
|
const { content: next, removed } = removeCodexServerBlock(content, "recordings");
|
|
7265
8538
|
writeFileSync4(configPath, next, "utf-8");
|
|
@@ -7277,7 +8550,7 @@ program.command("mcp").description("Install recordings MCP server into Claude Co
|
|
|
7277
8550
|
const configPath = pathJoin2(home, ".gemini", "settings.json");
|
|
7278
8551
|
let config = {};
|
|
7279
8552
|
if (fileExists(configPath)) {
|
|
7280
|
-
config = JSON.parse(
|
|
8553
|
+
config = JSON.parse(readFileSync7(configPath, "utf-8"));
|
|
7281
8554
|
}
|
|
7282
8555
|
const servers = config["mcpServers"] || {};
|
|
7283
8556
|
if (opts.uninstall) {
|
|
@@ -7316,7 +8589,7 @@ program.command("feedback <message>").description("Send feedback").option("--ema
|
|
|
7316
8589
|
function getMacOSAppStatus() {
|
|
7317
8590
|
const packageRoot = findPackageRoot();
|
|
7318
8591
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
7319
|
-
const canonicalAppPath = pathJoin(home, "Applications", "
|
|
8592
|
+
const canonicalAppPath = pathJoin(home, "Applications", "Hasna Recordings.app");
|
|
7320
8593
|
const legacyInstallPaths = findLegacyMacOSAppPaths(home, canonicalAppPath);
|
|
7321
8594
|
const installedAppPath = resolveInstalledAppPath(home, canonicalAppPath, legacyInstallPaths);
|
|
7322
8595
|
const installedAppPathForGrants = existsSync8(installedAppPath) ? installedAppPath : null;
|
|
@@ -7364,7 +8637,7 @@ function buildPermissionWarnings(status) {
|
|
|
7364
8637
|
warnings.push(`no app bundle exists at ${status.installed_app_path}, so the states above describe no ` + "installed code \u2014 install the app before trusting them");
|
|
7365
8638
|
}
|
|
7366
8639
|
if (status.ambiguous_installations) {
|
|
7367
|
-
warnings.push("more than one
|
|
8640
|
+
warnings.push("more than one Hasna Recordings.app is installed, so the states above may describe a bundle " + `other than the one macOS granted: reporting on ${status.installed_app_path}, also ` + `present are ${status.legacy_install_paths.join(", ")}`);
|
|
7368
8641
|
}
|
|
7369
8642
|
for (const [service, durability] of [
|
|
7370
8643
|
["Microphone", status.microphone_grant_durability],
|
|
@@ -7387,8 +8660,11 @@ function buildPermissionWarnings(status) {
|
|
|
7387
8660
|
function resolveInstalledAppPath(home, canonicalPath, legacyPaths) {
|
|
7388
8661
|
const preference = [
|
|
7389
8662
|
canonicalPath,
|
|
8663
|
+
pathJoin("/", "Applications", "Hasna Recordings.app"),
|
|
8664
|
+
pathJoin(home, "Applications", "HasnaRecordings.app"),
|
|
7390
8665
|
pathJoin("/", "Applications", "HasnaRecordings.app"),
|
|
7391
8666
|
pathJoin("/", "Applications", "Recordings.app"),
|
|
8667
|
+
pathJoin(home, ".hasna", "recordings", "Hasna Recordings.app"),
|
|
7392
8668
|
pathJoin(home, ".hasna", "recordings", "HasnaRecordings.app"),
|
|
7393
8669
|
pathJoin(home, ".hasna", "recordings", "Recordings.app")
|
|
7394
8670
|
];
|
|
@@ -7400,15 +8676,18 @@ function resolveInstalledAppPath(home, canonicalPath, legacyPaths) {
|
|
|
7400
8676
|
}
|
|
7401
8677
|
function findLegacyMacOSAppPaths(home, canonicalPath) {
|
|
7402
8678
|
const candidates = [
|
|
7403
|
-
pathJoin(home, "
|
|
8679
|
+
pathJoin(home, "Applications", "HasnaRecordings.app"),
|
|
7404
8680
|
pathJoin(home, ".hasna", "recordings", "HasnaRecordings.app"),
|
|
8681
|
+
pathJoin("/", "Applications", "HasnaRecordings.app"),
|
|
8682
|
+
pathJoin(home, ".hasna", "recordings", "Recordings.app"),
|
|
8683
|
+
pathJoin(home, ".hasna", "recordings", "Hasna Recordings.app"),
|
|
7405
8684
|
pathJoin("/", "Applications", "Recordings.app"),
|
|
7406
|
-
pathJoin("/", "Applications", "
|
|
8685
|
+
pathJoin("/", "Applications", "Hasna Recordings.app")
|
|
7407
8686
|
];
|
|
7408
8687
|
const userApplications = pathJoin(home, "Applications");
|
|
7409
8688
|
if (existsSync8(userApplications)) {
|
|
7410
8689
|
for (const entry of readdirSync2(userApplications, { withFileTypes: true })) {
|
|
7411
|
-
if (entry.isDirectory() && (entry.name.startsWith("Recordings.app.") || entry.name.startsWith("HasnaRecordings.app."))) {
|
|
8690
|
+
if (entry.isDirectory() && (entry.name.startsWith("Recordings.app.") || entry.name.startsWith("Hasna Recordings.app.") || entry.name.startsWith("HasnaRecordings.app."))) {
|
|
7412
8691
|
candidates.push(pathJoin(userApplications, entry.name));
|
|
7413
8692
|
}
|
|
7414
8693
|
}
|
|
@@ -7418,7 +8697,7 @@ function findLegacyMacOSAppPaths(home, canonicalPath) {
|
|
|
7418
8697
|
function resetMacOSPermissions() {
|
|
7419
8698
|
const services = ["Microphone", "Accessibility"];
|
|
7420
8699
|
for (const service of services) {
|
|
7421
|
-
const result =
|
|
8700
|
+
const result = spawnSync9("/usr/bin/tccutil", ["reset", service, RECORDINGS_BUNDLE_IDENTIFIER], { stdio: "inherit" });
|
|
7422
8701
|
if (result.error) {
|
|
7423
8702
|
console.error(chalk.red(result.error.message));
|
|
7424
8703
|
process.exit(1);
|
|
@@ -7436,7 +8715,7 @@ function getCodeSigningInfo(appPath) {
|
|
|
7436
8715
|
authorities: []
|
|
7437
8716
|
};
|
|
7438
8717
|
}
|
|
7439
|
-
const result =
|
|
8718
|
+
const result = spawnSync9("/usr/bin/codesign", ["-d", "-r-", "--verbose=4", appPath], {
|
|
7440
8719
|
encoding: "utf8",
|
|
7441
8720
|
stdio: ["ignore", "pipe", "pipe"]
|
|
7442
8721
|
});
|
|
@@ -7465,7 +8744,7 @@ function findPackageRoot() {
|
|
|
7465
8744
|
const packagePath = pathJoin(current, "package.json");
|
|
7466
8745
|
if (existsSync8(packagePath)) {
|
|
7467
8746
|
try {
|
|
7468
|
-
const pkg = JSON.parse(
|
|
8747
|
+
const pkg = JSON.parse(readFileSync6(packagePath, "utf8"));
|
|
7469
8748
|
if (pkg.name === "@hasna/recordings") {
|
|
7470
8749
|
return current;
|
|
7471
8750
|
}
|
|
@@ -7481,6 +8760,17 @@ function findPackageRoot() {
|
|
|
7481
8760
|
function getMacOSInstallerPath(packageRoot = findPackageRoot()) {
|
|
7482
8761
|
return pathJoin(packageRoot, "scripts", "install_macos_app.sh");
|
|
7483
8762
|
}
|
|
8763
|
+
async function printJSON(value) {
|
|
8764
|
+
await new Promise((resolve4, reject) => {
|
|
8765
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}
|
|
8766
|
+
`, (error) => {
|
|
8767
|
+
if (error)
|
|
8768
|
+
reject(error);
|
|
8769
|
+
else
|
|
8770
|
+
resolve4();
|
|
8771
|
+
});
|
|
8772
|
+
});
|
|
8773
|
+
}
|
|
7484
8774
|
program.parseAsync().catch((error) => {
|
|
7485
8775
|
const msg = error instanceof Error ? error.message : String(error);
|
|
7486
8776
|
console.error(`ERROR: ${msg}`);
|