@hasna/workflows 0.1.4 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -98
- package/dist/agent-tools.js +554 -1418
- package/dist/cli/index.js +4120 -2474
- package/dist/handlers.js +3339 -55
- package/dist/index.js +2372 -1253
- package/dist/mcp/index.js +1738 -1744
- package/dist/sdk.js +1620 -0
- package/dist/serve/admin.js +4291 -0
- package/dist/serve/index.js +3392 -1381
- package/dist/service.js +2134 -1165
- package/hasna.contract.json +17 -17
- package/package.json +16 -13
- package/types/agent-tools.d.ts +3 -17
- package/types/client-error.d.ts +4 -0
- package/types/client-tools.d.ts +4 -0
- package/types/client.d.ts +60 -0
- package/types/daemon.d.ts +15 -9
- package/types/db/postgres-store.d.ts +54 -0
- package/types/graph.d.ts +6 -0
- package/types/handlers.d.ts +2 -1
- package/types/identity.d.ts +7 -0
- package/types/lanes/codex.d.ts +0 -6
- package/types/lanes/types.d.ts +16 -3
- package/types/openapi.d.ts +7 -0
- package/types/paths.d.ts +10 -1
- package/types/sdk/generated-api.d.ts +161 -0
- package/types/sdk.d.ts +6 -0
- package/types/serve/admin.d.ts +2 -0
- package/types/service.d.ts +75 -4
- package/types/session.d.ts +7 -8
- package/types/setup.d.ts +71 -0
- package/types/store.d.ts +30 -0
- package/types/tool-definitions.d.ts +18 -0
- package/types/types.d.ts +8 -0
- package/types/version.d.ts +1 -0
- package/types/wal.d.ts +9 -1
package/dist/sdk.js
ADDED
|
@@ -0,0 +1,1620 @@
|
|
|
1
|
+
// ../../../../workflows-codex-execution-repair-1/dependency-update-1/standalone/workflows/node_modules/@hasna/contracts/dist/client/transport.js
|
|
2
|
+
import { isIP } from "net";
|
|
3
|
+
import { spawnSync } from "child_process";
|
|
4
|
+
import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync2 } from "fs";
|
|
5
|
+
import { O_NOFOLLOW as O_NOFOLLOW2, O_NONBLOCK as O_NONBLOCK2, O_RDONLY as O_RDONLY2 } from "constants";
|
|
6
|
+
import { hostname as osHostname } from "os";
|
|
7
|
+
import { isAbsolute, join as join2 } from "path";
|
|
8
|
+
import { createRequire } from "module";
|
|
9
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
10
|
+
import { closeSync, fstatSync, lstatSync, openSync, readFileSync, realpathSync, statSync } from "fs";
|
|
11
|
+
import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
|
|
12
|
+
import { basename, dirname, join, relative, sep } from "path";
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
14
|
+
function envToken(name) {
|
|
15
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
16
|
+
}
|
|
17
|
+
function clientTransportEnvKeys(name) {
|
|
18
|
+
const envSegment = envToken(name);
|
|
19
|
+
return {
|
|
20
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
21
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function credentialOverrideEnvKey(name) {
|
|
25
|
+
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
26
|
+
}
|
|
27
|
+
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
28
|
+
function credentialPointerEnvKey(name) {
|
|
29
|
+
return `HASNA_${envToken(name)}_API_KEY_REF`;
|
|
30
|
+
}
|
|
31
|
+
function e(e2, n, r) {
|
|
32
|
+
throw new Error(r ? `No known conditions for "${n}" specifier in "${e2}" package` : `Missing "${n}" specifier in "${e2}" package`);
|
|
33
|
+
}
|
|
34
|
+
function n(n2, i, o, f) {
|
|
35
|
+
let s, u, l = r(n2, o), c = function(e2) {
|
|
36
|
+
let n3 = new Set(["default", ...e2.conditions || []]);
|
|
37
|
+
return e2.unsafe || n3.add(e2.require ? "require" : "import"), e2.unsafe || n3.add(e2.browser ? "browser" : "node"), n3;
|
|
38
|
+
}(f || {}), a = i[l];
|
|
39
|
+
if (a === undefined) {
|
|
40
|
+
let e2, n3, r, t;
|
|
41
|
+
for (t in i)
|
|
42
|
+
n3 && t.length < n3.length || (t[t.length - 1] === "/" && l.startsWith(t) ? (u = l.substring(t.length), n3 = t) : t.length > 1 && (r = t.indexOf("*", 1), ~r && (e2 = RegExp("^" + t.substring(0, r) + "(.*)" + t.substring(1 + r) + "$").exec(l), e2 && e2[1] && (u = e2[1], n3 = t))));
|
|
43
|
+
a = i[n3];
|
|
44
|
+
}
|
|
45
|
+
return a || e(n2, l), s = t(a, c), s || e(n2, l, 1), u && function(e2, n3) {
|
|
46
|
+
let r, t = 0, i2 = e2.length, o2 = /[*]/g, f2 = /[/]$/;
|
|
47
|
+
for (;t < i2; t++)
|
|
48
|
+
e2[t] = o2.test(r = e2[t]) ? r.replace(o2, n3) : f2.test(r) ? r + n3 : r;
|
|
49
|
+
}(s, u), s;
|
|
50
|
+
}
|
|
51
|
+
function r(e2, n2, r2) {
|
|
52
|
+
if (e2 === n2 || n2 === ".")
|
|
53
|
+
return ".";
|
|
54
|
+
let t = e2 + "/", i = t.length, o = n2.slice(0, i) === t, f = o ? n2.slice(i) : n2;
|
|
55
|
+
return f[0] === "#" ? f : o || !r2 ? f.slice(0, 2) === "./" ? f : "./" + f : f;
|
|
56
|
+
}
|
|
57
|
+
function t(e2, n2, r2) {
|
|
58
|
+
if (e2) {
|
|
59
|
+
if (typeof e2 == "string")
|
|
60
|
+
return r2 && r2.add(e2), [e2];
|
|
61
|
+
let i, o;
|
|
62
|
+
if (Array.isArray(e2)) {
|
|
63
|
+
for (o = r2 || new Set, i = 0;i < e2.length; i++)
|
|
64
|
+
t(e2[i], n2, o);
|
|
65
|
+
if (!r2 && o.size)
|
|
66
|
+
return [...o];
|
|
67
|
+
} else
|
|
68
|
+
for (i in e2)
|
|
69
|
+
if (n2.has(i))
|
|
70
|
+
return t(e2[i], n2, r2);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function o(e2, r2, t2) {
|
|
74
|
+
let i, o2 = e2.exports;
|
|
75
|
+
if (o2) {
|
|
76
|
+
if (typeof o2 == "string")
|
|
77
|
+
o2 = { ".": o2 };
|
|
78
|
+
else
|
|
79
|
+
for (i in o2) {
|
|
80
|
+
i[0] !== "." && (o2 = { ".": o2 });
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
return n(e2.name, o2, r2 || ".", t2);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
var PACKAGE = "@hasna/secrets";
|
|
87
|
+
var MAX_PACKAGE_BYTES = 1024 * 1024;
|
|
88
|
+
function entryPoint(directory) {
|
|
89
|
+
const root = realpathSync(directory);
|
|
90
|
+
const fd = openSync(join(root, "package.json"), O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
91
|
+
let pkg;
|
|
92
|
+
try {
|
|
93
|
+
const stat = fstatSync(fd);
|
|
94
|
+
if (!stat.isFile() || stat.size > MAX_PACKAGE_BYTES)
|
|
95
|
+
throw new Error("Invalid Secrets package metadata");
|
|
96
|
+
pkg = JSON.parse(readFileSync(fd, "utf8"));
|
|
97
|
+
} finally {
|
|
98
|
+
closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
if (!pkg || typeof pkg !== "object" || !("name" in pkg) || pkg.name !== PACKAGE) {
|
|
101
|
+
throw new Error("Invalid Secrets package identity");
|
|
102
|
+
}
|
|
103
|
+
const target = o(pkg, ".")?.[0];
|
|
104
|
+
if (typeof target !== "string" || !target.startsWith("./"))
|
|
105
|
+
throw new Error("Missing Secrets import export");
|
|
106
|
+
const parts = target.slice(2).split("/");
|
|
107
|
+
if (/[\\%?#\0]/.test(target) || parts.some((part) => !part || part === "." || part === ".." || part === "node_modules")) {
|
|
108
|
+
throw new Error("Invalid Secrets import export");
|
|
109
|
+
}
|
|
110
|
+
const file = realpathSync(join(root, ...parts));
|
|
111
|
+
const within = relative(root, file);
|
|
112
|
+
if (!within || within === ".." || within.startsWith(`..${sep}`) || !statSync(file).isFile()) {
|
|
113
|
+
throw new Error("Secrets import export escapes its package or is not a file");
|
|
114
|
+
}
|
|
115
|
+
return pathToFileURL(file).href;
|
|
116
|
+
}
|
|
117
|
+
function resolveInstalledSecrets(parentUrl) {
|
|
118
|
+
let directory = dirname(fileURLToPath(parentUrl));
|
|
119
|
+
for (;; ) {
|
|
120
|
+
if (basename(directory) !== "node_modules") {
|
|
121
|
+
const candidate = join(directory, "node_modules", PACKAGE);
|
|
122
|
+
let absent = false;
|
|
123
|
+
try {
|
|
124
|
+
lstatSync(candidate);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error.code !== "ENOENT")
|
|
127
|
+
throw error;
|
|
128
|
+
absent = true;
|
|
129
|
+
}
|
|
130
|
+
if (!absent)
|
|
131
|
+
return entryPoint(candidate);
|
|
132
|
+
}
|
|
133
|
+
const parent = dirname(directory);
|
|
134
|
+
if (parent === directory)
|
|
135
|
+
throw new Error("Secrets SDK is not installed for this consumer");
|
|
136
|
+
directory = parent;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
class CredentialResolutionError extends Error {
|
|
141
|
+
appName;
|
|
142
|
+
attempted;
|
|
143
|
+
constructor(appName, message, attempted) {
|
|
144
|
+
super(message);
|
|
145
|
+
this.name = "CredentialResolutionError";
|
|
146
|
+
this.appName = appName;
|
|
147
|
+
this.attempted = attempted;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
class CredentialFileUnsafeError extends Error {
|
|
152
|
+
path;
|
|
153
|
+
constructor(path, reason) {
|
|
154
|
+
super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
|
|
155
|
+
this.name = "CredentialFileUnsafeError";
|
|
156
|
+
this.path = path;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
var HASNA_HOME_ENV_KEY = "HASNA_HOME";
|
|
160
|
+
var HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME";
|
|
161
|
+
var KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION";
|
|
162
|
+
var HASNA_HOME_DIR = ".hasna";
|
|
163
|
+
var CONFIG_SUBDIR = "config";
|
|
164
|
+
var CREDENTIALS_FILE = "credentials";
|
|
165
|
+
var KEYCHAIN_SECURITY_BIN = "/usr/bin/security";
|
|
166
|
+
var KEYCHAIN_SERVICE_PREFIX = "hasna.credentials";
|
|
167
|
+
var KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44;
|
|
168
|
+
var KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4;
|
|
169
|
+
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
170
|
+
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
171
|
+
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
172
|
+
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
173
|
+
var VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
|
|
174
|
+
function homeDir(env) {
|
|
175
|
+
const home = env.HOME?.trim();
|
|
176
|
+
return home ? home : null;
|
|
177
|
+
}
|
|
178
|
+
function absoluteOverride(env, key) {
|
|
179
|
+
const value = env[key]?.trim();
|
|
180
|
+
return value && isAbsolute(value) ? value : null;
|
|
181
|
+
}
|
|
182
|
+
function hasnaHomeDir(env) {
|
|
183
|
+
const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
|
|
184
|
+
if (override)
|
|
185
|
+
return override;
|
|
186
|
+
const home = homeDir(env);
|
|
187
|
+
return home ? join2(home, HASNA_HOME_DIR) : null;
|
|
188
|
+
}
|
|
189
|
+
function appConfigDir(name, env) {
|
|
190
|
+
const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
|
|
191
|
+
if (configRoot)
|
|
192
|
+
return join2(configRoot, name);
|
|
193
|
+
const root = hasnaHomeDir(env);
|
|
194
|
+
return root ? join2(root, name, CONFIG_SUBDIR) : null;
|
|
195
|
+
}
|
|
196
|
+
function credentialDiskSourceList(name, env, profile = null) {
|
|
197
|
+
if (!SAFE_APP_SLUG.test(name))
|
|
198
|
+
return [];
|
|
199
|
+
const directory = appConfigDir(name, env);
|
|
200
|
+
if (!directory)
|
|
201
|
+
return [];
|
|
202
|
+
const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
|
|
203
|
+
return [{ path: join2(directory, file), tier: "disk" }];
|
|
204
|
+
}
|
|
205
|
+
function credentialDiskSources(name, env) {
|
|
206
|
+
return credentialDiskSourceList(name, env, null).map((s) => s.path);
|
|
207
|
+
}
|
|
208
|
+
function profileDiskSources(name, env, profile) {
|
|
209
|
+
return credentialDiskSourceList(name, env, profile).map((s) => s.path);
|
|
210
|
+
}
|
|
211
|
+
function parseEnvFile(text) {
|
|
212
|
+
const values = new Map;
|
|
213
|
+
const unusable = new Set;
|
|
214
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
215
|
+
const line = rawLine.trim();
|
|
216
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
217
|
+
continue;
|
|
218
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
219
|
+
const equals = withoutExport.indexOf("=");
|
|
220
|
+
if (equals <= 0)
|
|
221
|
+
continue;
|
|
222
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
223
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
224
|
+
continue;
|
|
225
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
226
|
+
const quote = value[0];
|
|
227
|
+
if (quote === '"' || quote === "'") {
|
|
228
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
229
|
+
unusable.add(key);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
value = value.slice(1, -1);
|
|
233
|
+
}
|
|
234
|
+
if (value.trim().length === 0) {
|
|
235
|
+
unusable.add(key);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (values.has(key) && values.get(key) !== value)
|
|
239
|
+
unusable.add(key);
|
|
240
|
+
values.set(key, value);
|
|
241
|
+
}
|
|
242
|
+
return { values, unusable };
|
|
243
|
+
}
|
|
244
|
+
function configFileModeAllowed(mode) {
|
|
245
|
+
const permissions = mode & 4095;
|
|
246
|
+
return permissions === 256 || permissions === 384;
|
|
247
|
+
}
|
|
248
|
+
function configFileReadsCoherent(before, after) {
|
|
249
|
+
return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
250
|
+
}
|
|
251
|
+
function readAppConfigFile(path) {
|
|
252
|
+
const unsafe = (reason) => {
|
|
253
|
+
throw new CredentialFileUnsafeError(path, reason);
|
|
254
|
+
};
|
|
255
|
+
let fd = -1;
|
|
256
|
+
try {
|
|
257
|
+
fd = openSync2(path, O_RDONLY2 | O_NOFOLLOW2 | O_NONBLOCK2);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const code = error.code;
|
|
260
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
261
|
+
return null;
|
|
262
|
+
if (code === "ELOOP")
|
|
263
|
+
unsafe("the path is a symlink");
|
|
264
|
+
unsafe(`the path could not be opened (${code ?? "unknown error"})`);
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
const before = fstatSync2(fd);
|
|
268
|
+
if (!before.isFile())
|
|
269
|
+
unsafe("the path is not a regular file");
|
|
270
|
+
if (!configFileModeAllowed(before.mode)) {
|
|
271
|
+
unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
|
|
272
|
+
}
|
|
273
|
+
const uid = process.getuid?.() ?? process.geteuid?.();
|
|
274
|
+
if (uid !== undefined && before.uid !== uid)
|
|
275
|
+
unsafe("the file is not owned by the current user");
|
|
276
|
+
if (before.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
277
|
+
unsafe("the file exceeds the size limit");
|
|
278
|
+
const bytes = readFileSync2(fd);
|
|
279
|
+
const after = fstatSync2(fd);
|
|
280
|
+
if (!configFileReadsCoherent(before, after)) {
|
|
281
|
+
unsafe("the file changed while being read");
|
|
282
|
+
}
|
|
283
|
+
return parseEnvFile(bytes.toString("utf8"));
|
|
284
|
+
} finally {
|
|
285
|
+
if (fd !== -1)
|
|
286
|
+
closeSync2(fd);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function readCredentialFile(path, apiKeyKeys, pointerKey) {
|
|
290
|
+
const parsed = readAppConfigFile(path);
|
|
291
|
+
if (!parsed)
|
|
292
|
+
return null;
|
|
293
|
+
for (const key of [...apiKeyKeys, pointerKey]) {
|
|
294
|
+
if (parsed.unusable.has(key)) {
|
|
295
|
+
throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
299
|
+
const pointer = parsed.values.get(pointerKey)?.trim();
|
|
300
|
+
if (pointer !== undefined) {
|
|
301
|
+
if (!VAULT_POINTER_SHAPE.test(pointer))
|
|
302
|
+
throw new CredentialFileUnsafeError(path, `${pointerKey} must name a vault item`);
|
|
303
|
+
if (values.length)
|
|
304
|
+
throw new CredentialFileUnsafeError(path, "a credential file cannot select both a literal key and a vault reference");
|
|
305
|
+
return { apiKey: "", pointerVaultKey: pointer };
|
|
306
|
+
}
|
|
307
|
+
if (new Set(values).size > 1) {
|
|
308
|
+
throw new CredentialFileUnsafeError(path, "credential aliases disagree");
|
|
309
|
+
}
|
|
310
|
+
return values[0] === undefined ? null : { apiKey: values[0] };
|
|
311
|
+
}
|
|
312
|
+
var CREDENTIAL_SHAPED_KEY = /(?:^|_)(?:API_KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)(?:_|$)/;
|
|
313
|
+
function appConfigDiskValue(name, env, keys, profile = null) {
|
|
314
|
+
if (profile !== null && !SAFE_PROFILE.test(profile)) {
|
|
315
|
+
throw new CredentialResolutionError(name, "The selected configuration profile is invalid.", ["profile"]);
|
|
316
|
+
}
|
|
317
|
+
const wanted = keys.filter((key) => !CREDENTIAL_SHAPED_KEY.test(key));
|
|
318
|
+
if (wanted.length === 0)
|
|
319
|
+
return null;
|
|
320
|
+
for (const path of profileDiskSources(name, env, profile)) {
|
|
321
|
+
const parsed = readAppConfigFile(path);
|
|
322
|
+
if (!parsed)
|
|
323
|
+
continue;
|
|
324
|
+
if (wanted.some((key) => parsed.unusable.has(key))) {
|
|
325
|
+
return { key: wanted.find((key) => parsed.unusable.has(key)), value: "", path, unusable: true };
|
|
326
|
+
}
|
|
327
|
+
const values = wanted.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
|
|
328
|
+
if (new Set(values).size > 1)
|
|
329
|
+
throw new CredentialFileUnsafeError(path, "configuration aliases disagree");
|
|
330
|
+
for (const key of wanted) {
|
|
331
|
+
if (parsed.unusable.has(key))
|
|
332
|
+
return { key, value: "", path, unusable: true };
|
|
333
|
+
const value = parsed.values.get(key)?.trim();
|
|
334
|
+
if (value)
|
|
335
|
+
return { key, value, path };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
function assertUsableCredential(appName, source, value) {
|
|
341
|
+
if (VAULT_POINTER_SHAPE.test(value)) {
|
|
342
|
+
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]);
|
|
343
|
+
}
|
|
344
|
+
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
345
|
+
return;
|
|
346
|
+
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]);
|
|
347
|
+
}
|
|
348
|
+
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
349
|
+
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
350
|
+
var CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider";
|
|
351
|
+
function sealCredential(fields) {
|
|
352
|
+
const { apiKey } = fields;
|
|
353
|
+
const visible = {
|
|
354
|
+
tier: fields.tier,
|
|
355
|
+
source: fields.source,
|
|
356
|
+
deliberate: fields.deliberate,
|
|
357
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
358
|
+
warning: fields.warning
|
|
359
|
+
};
|
|
360
|
+
const sealed = { ...visible };
|
|
361
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
362
|
+
value: apiKey,
|
|
363
|
+
enumerable: false,
|
|
364
|
+
writable: false,
|
|
365
|
+
configurable: false
|
|
366
|
+
});
|
|
367
|
+
if (fields.pointerVaultKey !== undefined) {
|
|
368
|
+
Object.defineProperty(sealed, "pointerVaultKey", {
|
|
369
|
+
value: fields.pointerVaultKey,
|
|
370
|
+
enumerable: false,
|
|
371
|
+
writable: false,
|
|
372
|
+
configurable: false
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
376
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
377
|
+
enumerable: false,
|
|
378
|
+
writable: false,
|
|
379
|
+
configurable: false
|
|
380
|
+
});
|
|
381
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL, {
|
|
382
|
+
value: true,
|
|
383
|
+
enumerable: false,
|
|
384
|
+
writable: false,
|
|
385
|
+
configurable: false
|
|
386
|
+
});
|
|
387
|
+
return Object.freeze(sealed);
|
|
388
|
+
}
|
|
389
|
+
function isSealedCredential(credential) {
|
|
390
|
+
return credential[CREDENTIAL_SEAL] === true;
|
|
391
|
+
}
|
|
392
|
+
function explicitCredential(appName, apiKey) {
|
|
393
|
+
const source = "explicit apiKey option";
|
|
394
|
+
assertUsableCredential(appName, source, apiKey);
|
|
395
|
+
return sealCredential({
|
|
396
|
+
apiKey,
|
|
397
|
+
tier: "argument",
|
|
398
|
+
source,
|
|
399
|
+
deliberate: true,
|
|
400
|
+
diskCandidates: [],
|
|
401
|
+
warning: null
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
function validateAndSealResolvedCredential(appName, credential) {
|
|
405
|
+
const apiKey = credential.apiKey;
|
|
406
|
+
assertUsableCredential(appName, CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE, apiKey);
|
|
407
|
+
if (!isSealedCredential(credential)) {
|
|
408
|
+
return sealCredential({
|
|
409
|
+
apiKey,
|
|
410
|
+
tier: "argument",
|
|
411
|
+
source: CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE,
|
|
412
|
+
deliberate: true,
|
|
413
|
+
diskCandidates: [],
|
|
414
|
+
warning: null
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return sealCredential({
|
|
418
|
+
apiKey,
|
|
419
|
+
tier: credential.tier,
|
|
420
|
+
source: credential.source,
|
|
421
|
+
deliberate: credential.deliberate,
|
|
422
|
+
diskCandidates: credential.diskCandidates,
|
|
423
|
+
warning: credential.warning,
|
|
424
|
+
...credential.pointerVaultKey !== undefined ? { pointerVaultKey: credential.pointerVaultKey } : {}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
function firstEnvValue(env, keys) {
|
|
428
|
+
for (const key of keys) {
|
|
429
|
+
if (!Object.prototype.hasOwnProperty.call(env, key))
|
|
430
|
+
continue;
|
|
431
|
+
const value = env[key]?.trim();
|
|
432
|
+
if (value)
|
|
433
|
+
return { key, value };
|
|
434
|
+
}
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
var AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
438
|
+
function isAmbientEnvironment(env) {
|
|
439
|
+
return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
|
|
440
|
+
}
|
|
441
|
+
function defaultKeychainRunner(argv) {
|
|
442
|
+
const result = spawnSync(KEYCHAIN_SECURITY_BIN, [...argv], {
|
|
443
|
+
encoding: "utf8",
|
|
444
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
445
|
+
timeout: KEYCHAIN_SPAWN_TIMEOUT_MS
|
|
446
|
+
});
|
|
447
|
+
return {
|
|
448
|
+
status: result.status,
|
|
449
|
+
stdout: result.stdout ?? "",
|
|
450
|
+
stderr: result.error ? result.error.message : result.stderr ?? ""
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function keychainTierEnabled(env, options) {
|
|
454
|
+
if ((options.platform ?? process.platform) !== "darwin")
|
|
455
|
+
return false;
|
|
456
|
+
if (options.enabled !== undefined)
|
|
457
|
+
return options.enabled;
|
|
458
|
+
return options.run !== undefined || isAmbientEnvironment(env);
|
|
459
|
+
}
|
|
460
|
+
function keychainAccount(env, options) {
|
|
461
|
+
const station = env[KEYCHAIN_STATION_ENV_KEY]?.trim();
|
|
462
|
+
if (station)
|
|
463
|
+
return station;
|
|
464
|
+
const host = (options.hostname ?? osHostname)().split(".")[0]?.trim() ?? "";
|
|
465
|
+
if (host)
|
|
466
|
+
return host;
|
|
467
|
+
const user = env.USER?.trim();
|
|
468
|
+
return user || null;
|
|
469
|
+
}
|
|
470
|
+
function keychainFailureHint(text) {
|
|
471
|
+
const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
|
|
472
|
+
const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
|
|
473
|
+
return clean ? `: ${clean}` : "";
|
|
474
|
+
}
|
|
475
|
+
function readKeychainItem(name, env, kind, options) {
|
|
476
|
+
if (!SAFE_APP_SLUG.test(name) || !keychainTierEnabled(env, options))
|
|
477
|
+
return null;
|
|
478
|
+
const account = keychainAccount(env, options);
|
|
479
|
+
if (!account)
|
|
480
|
+
return null;
|
|
481
|
+
const service = `${KEYCHAIN_SERVICE_PREFIX}.${name}.${kind}`;
|
|
482
|
+
const source = `keychain:${service}@${account}`;
|
|
483
|
+
const run = options.run ?? defaultKeychainRunner;
|
|
484
|
+
let result;
|
|
485
|
+
try {
|
|
486
|
+
result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
|
|
487
|
+
} catch (error) {
|
|
488
|
+
const reason = keychainFailureHint(error instanceof Error ? error.message : String(error));
|
|
489
|
+
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]);
|
|
490
|
+
}
|
|
491
|
+
if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS)
|
|
492
|
+
return null;
|
|
493
|
+
if (result.status !== 0) {
|
|
494
|
+
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]);
|
|
495
|
+
}
|
|
496
|
+
const value = result.stdout.trim();
|
|
497
|
+
if (!value) {
|
|
498
|
+
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]);
|
|
499
|
+
}
|
|
500
|
+
return { value, source };
|
|
501
|
+
}
|
|
502
|
+
function keychainConfigValue(name, env, options = {}) {
|
|
503
|
+
return readKeychainItem(name, env, "api-url", options);
|
|
504
|
+
}
|
|
505
|
+
function snapshotClientEnvironment(name, env) {
|
|
506
|
+
const keys = clientTransportEnvKeys(name);
|
|
507
|
+
const ambient = isAmbientEnvironment(env);
|
|
508
|
+
const snapshot = Object.create(null);
|
|
509
|
+
for (const key of [
|
|
510
|
+
...keys.apiUrlKeys,
|
|
511
|
+
...keys.apiKeyKeys,
|
|
512
|
+
credentialOverrideEnvKey(name),
|
|
513
|
+
credentialPointerEnvKey(name),
|
|
514
|
+
CREDENTIAL_PROFILE_ENV_KEY,
|
|
515
|
+
"HOME",
|
|
516
|
+
HASNA_HOME_ENV_KEY,
|
|
517
|
+
HASNA_CONFIG_HOME_ENV_KEY,
|
|
518
|
+
KEYCHAIN_STATION_ENV_KEY,
|
|
519
|
+
"USER"
|
|
520
|
+
]) {
|
|
521
|
+
const descriptor = Object.getOwnPropertyDescriptor(env, key);
|
|
522
|
+
if (!descriptor)
|
|
523
|
+
continue;
|
|
524
|
+
if (!("value" in descriptor)) {
|
|
525
|
+
throw new CredentialResolutionError(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
|
|
526
|
+
}
|
|
527
|
+
if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
|
|
528
|
+
throw new CredentialResolutionError(name, `${key} must be a string data property.`, [key]);
|
|
529
|
+
}
|
|
530
|
+
snapshot[key] = descriptor.value;
|
|
531
|
+
}
|
|
532
|
+
if (ambient) {
|
|
533
|
+
Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
|
|
534
|
+
value: true,
|
|
535
|
+
enumerable: false,
|
|
536
|
+
writable: false,
|
|
537
|
+
configurable: false
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
return Object.freeze(snapshot);
|
|
541
|
+
}
|
|
542
|
+
function resolveCredential(name, env, options = {}) {
|
|
543
|
+
env = snapshotClientEnvironment(name, env);
|
|
544
|
+
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
545
|
+
const diskPaths = credentialDiskSources(name, env);
|
|
546
|
+
if (options.apiKey !== undefined) {
|
|
547
|
+
const explicitKey = options.apiKey.trim();
|
|
548
|
+
if (!explicitKey) {
|
|
549
|
+
throw new CredentialResolutionError(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
|
|
550
|
+
}
|
|
551
|
+
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
552
|
+
return sealCredential({
|
|
553
|
+
apiKey: explicitKey,
|
|
554
|
+
tier: "argument",
|
|
555
|
+
source: "explicit apiKey argument",
|
|
556
|
+
deliberate: true,
|
|
557
|
+
diskCandidates: diskPaths,
|
|
558
|
+
warning: null
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
562
|
+
const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
|
|
563
|
+
if (overrideRaw !== undefined) {
|
|
564
|
+
const override = overrideRaw.trim();
|
|
565
|
+
if (!override) {
|
|
566
|
+
throw new CredentialResolutionError(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]);
|
|
567
|
+
}
|
|
568
|
+
assertUsableCredential(name, overrideKeyName, override);
|
|
569
|
+
return sealCredential({
|
|
570
|
+
apiKey: override,
|
|
571
|
+
tier: "override",
|
|
572
|
+
source: overrideKeyName,
|
|
573
|
+
deliberate: true,
|
|
574
|
+
diskCandidates: diskPaths,
|
|
575
|
+
warning: null
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
const pointerKeyName = credentialPointerEnvKey(name);
|
|
579
|
+
const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
|
|
580
|
+
if (pointerRaw !== undefined) {
|
|
581
|
+
const pointer = pointerRaw.trim();
|
|
582
|
+
if (!pointer) {
|
|
583
|
+
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]);
|
|
584
|
+
}
|
|
585
|
+
if (!VAULT_POINTER_SHAPE.test(pointer)) {
|
|
586
|
+
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]);
|
|
587
|
+
}
|
|
588
|
+
return sealCredential({
|
|
589
|
+
apiKey: "",
|
|
590
|
+
pointerVaultKey: pointer,
|
|
591
|
+
tier: "pointer",
|
|
592
|
+
source: pointerKeyName,
|
|
593
|
+
deliberate: true,
|
|
594
|
+
diskCandidates: diskPaths,
|
|
595
|
+
warning: null
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
if (options.profile !== undefined && !options.profile.trim()) {
|
|
599
|
+
throw new CredentialResolutionError(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
|
|
600
|
+
}
|
|
601
|
+
const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY) ? env[CREDENTIAL_PROFILE_ENV_KEY] : undefined;
|
|
602
|
+
if (profileRaw !== undefined && !profileRaw.trim()) {
|
|
603
|
+
throw new CredentialResolutionError(name, `${CREDENTIAL_PROFILE_ENV_KEY} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY]);
|
|
604
|
+
}
|
|
605
|
+
const profile = options.profile?.trim() || profileRaw?.trim();
|
|
606
|
+
if (profile) {
|
|
607
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
608
|
+
if (!SAFE_PROFILE.test(profile)) {
|
|
609
|
+
throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
610
|
+
}
|
|
611
|
+
const paths = profileDiskSources(name, env, profile);
|
|
612
|
+
for (const path of paths) {
|
|
613
|
+
const value = readCredentialFile(path, apiKeyKeys, pointerKeyName);
|
|
614
|
+
if (value) {
|
|
615
|
+
if (!value.pointerVaultKey)
|
|
616
|
+
assertUsableCredential(name, path, value.apiKey);
|
|
617
|
+
return sealCredential({
|
|
618
|
+
...value,
|
|
619
|
+
tier: value.pointerVaultKey ? "pointer" : "profile",
|
|
620
|
+
source: path,
|
|
621
|
+
deliberate: true,
|
|
622
|
+
diskCandidates: paths,
|
|
623
|
+
warning: null
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} or ${pointerKeyName} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around — ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY}.`, paths);
|
|
628
|
+
}
|
|
629
|
+
const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
|
|
630
|
+
const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
|
|
631
|
+
if (blankEnv) {
|
|
632
|
+
throw new CredentialResolutionError(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
|
|
633
|
+
}
|
|
634
|
+
if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
|
|
635
|
+
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));
|
|
636
|
+
}
|
|
637
|
+
const envHit = firstEnvValue(env, apiKeyKeys);
|
|
638
|
+
const keychainHit = readKeychainItem(name, env, "api-key", options.keychain ?? {});
|
|
639
|
+
if (keychainHit) {
|
|
640
|
+
assertUsableCredential(name, keychainHit.source, keychainHit.value);
|
|
641
|
+
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 — a rotation that updated only one leaves ` + `the other to fail 401 wherever it is loaded first.` : null;
|
|
642
|
+
return sealCredential({
|
|
643
|
+
apiKey: keychainHit.value,
|
|
644
|
+
tier: "keychain",
|
|
645
|
+
source: keychainHit.source,
|
|
646
|
+
deliberate: false,
|
|
647
|
+
diskCandidates: diskPaths,
|
|
648
|
+
warning
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
const diskSourceList = credentialDiskSourceList(name, env, null);
|
|
652
|
+
const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys, pointerKeyName) })).filter((hit) => hit.value !== null);
|
|
653
|
+
if (diskHits.length > 0) {
|
|
654
|
+
const winner = diskHits[0];
|
|
655
|
+
if (winner.value.pointerVaultKey) {
|
|
656
|
+
return sealCredential({
|
|
657
|
+
...winner.value,
|
|
658
|
+
tier: "pointer",
|
|
659
|
+
source: winner.src.path,
|
|
660
|
+
deliberate: false,
|
|
661
|
+
diskCandidates: diskPaths,
|
|
662
|
+
warning: null
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
assertUsableCredential(name, winner.src.path, winner.value.apiKey);
|
|
666
|
+
const divergentSources = [
|
|
667
|
+
...diskHits.slice(1).filter((hit) => hit.value.apiKey !== winner.value.apiKey || hit.value.pointerVaultKey !== winner.value.pointerVaultKey).map((hit) => hit.src.path),
|
|
668
|
+
...envHit && envHit.value !== winner.value.apiKey ? [envHit.key] : []
|
|
669
|
+
];
|
|
670
|
+
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 — ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
|
|
671
|
+
return sealCredential({
|
|
672
|
+
apiKey: winner.value.apiKey,
|
|
673
|
+
tier: winner.src.tier,
|
|
674
|
+
source: winner.src.path,
|
|
675
|
+
deliberate: false,
|
|
676
|
+
diskCandidates: diskPaths,
|
|
677
|
+
warning
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
if (envHit) {
|
|
681
|
+
assertUsableCredential(name, envHit.key, envHit.value);
|
|
682
|
+
return sealCredential({
|
|
683
|
+
apiKey: envHit.value,
|
|
684
|
+
tier: "env",
|
|
685
|
+
source: envHit.key,
|
|
686
|
+
deliberate: false,
|
|
687
|
+
diskCandidates: diskPaths,
|
|
688
|
+
warning: null
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
var requireSecretsSdk = createRequire(import.meta.url);
|
|
694
|
+
async function completePointerCredential(name, pointerResolution, env = process.env) {
|
|
695
|
+
const secretsEnv = snapshotClientEnvironment("secrets", env);
|
|
696
|
+
const vaultKey = pointerResolution.pointerVaultKey;
|
|
697
|
+
const pointerEnvKey = pointerResolution.source;
|
|
698
|
+
if (!vaultKey) {
|
|
699
|
+
throw new CredentialResolutionError(name, `Pointer resolution from ${pointerEnvKey} carries no vault item key; this is a defect in the resolver.`, [pointerEnvKey]);
|
|
700
|
+
}
|
|
701
|
+
if (name === "secrets") {
|
|
702
|
+
throw new CredentialResolutionError(name, "The Secrets bootstrap credential cannot reference the same hosted vault; configure an independent bootstrap provider.", [pointerEnvKey]);
|
|
703
|
+
}
|
|
704
|
+
let secretsSdk;
|
|
705
|
+
try {
|
|
706
|
+
const sdkUrl = resolveInstalledSecrets(import.meta.url);
|
|
707
|
+
secretsSdk = requireSecretsSdk(fileURLToPath2(sdkUrl));
|
|
708
|
+
} catch {
|
|
709
|
+
throw new CredentialResolutionError(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]);
|
|
710
|
+
}
|
|
711
|
+
let client;
|
|
712
|
+
try {
|
|
713
|
+
const bootstrap = resolveCredential("secrets", secretsEnv);
|
|
714
|
+
if (!bootstrap || bootstrap.tier === "pointer") {
|
|
715
|
+
throw new CredentialResolutionError("secrets", "The Secrets vault requires an independent, non-reference bootstrap credential.", bootstrap ? [bootstrap.source] : []);
|
|
716
|
+
}
|
|
717
|
+
client = secretsSdk.createSecretsClientFromEnv(secretsEnv);
|
|
718
|
+
} catch {
|
|
719
|
+
throw new CredentialResolutionError(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]);
|
|
720
|
+
}
|
|
721
|
+
let secret;
|
|
722
|
+
try {
|
|
723
|
+
secret = await client.getSecret({ key: vaultKey });
|
|
724
|
+
} catch {
|
|
725
|
+
throw new CredentialResolutionError(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]);
|
|
726
|
+
}
|
|
727
|
+
const value = secret.value;
|
|
728
|
+
if (!value) {
|
|
729
|
+
throw new CredentialResolutionError(name, `${pointerEnvKey} resolved vault item '${vaultKey}', but it holds no value. A vault pointer is TERMINAL.`, [pointerEnvKey]);
|
|
730
|
+
}
|
|
731
|
+
assertUsableCredential(name, `${pointerEnvKey} -> vault:${vaultKey}`, value);
|
|
732
|
+
return sealCredential({
|
|
733
|
+
apiKey: value,
|
|
734
|
+
tier: "pointer",
|
|
735
|
+
source: `${pointerEnvKey} -> vault:${vaultKey}`,
|
|
736
|
+
deliberate: pointerResolution.deliberate,
|
|
737
|
+
diskCandidates: pointerResolution.diskCandidates,
|
|
738
|
+
warning: null
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
var DEFAULT_FLEET_GATEWAY_ORIGIN = "https://api.hasna.com";
|
|
742
|
+
var DEFAULT_AUTHORITY_SOURCE = "default";
|
|
743
|
+
function defaultFleetGatewayBaseUrl(name) {
|
|
744
|
+
return `${DEFAULT_FLEET_GATEWAY_ORIGIN}/${validateAppSlug(name)}`;
|
|
745
|
+
}
|
|
746
|
+
var ASCII_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
747
|
+
var DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
748
|
+
function isValidDnsDomain(value) {
|
|
749
|
+
if (value.length === 0 || value.length > 253 || ASCII_CONTROL_PATTERN.test(value) || /[^\x00-\x7f]/.test(value)) {
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
return value.split(".").every((label) => label.length <= 63 && !label.startsWith("xn--") && DNS_LABEL_PATTERN.test(label));
|
|
753
|
+
}
|
|
754
|
+
function validateAppSlug(name) {
|
|
755
|
+
if (name.length > 63 || !DNS_LABEL_PATTERN.test(name)) {
|
|
756
|
+
throw new Error("App name must be one lowercase DNS label.");
|
|
757
|
+
}
|
|
758
|
+
return name;
|
|
759
|
+
}
|
|
760
|
+
function rawAuthority(value) {
|
|
761
|
+
const match = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value);
|
|
762
|
+
if (!match)
|
|
763
|
+
throw new Error("API URL must be absolute.");
|
|
764
|
+
const afterScheme = value.slice(match[0].length);
|
|
765
|
+
const boundary = afterScheme.search(/[/?#]/);
|
|
766
|
+
const authority = boundary === -1 ? afterScheme : afterScheme.slice(0, boundary);
|
|
767
|
+
if (!authority)
|
|
768
|
+
throw new Error("API URL must include a hostname.");
|
|
769
|
+
return authority;
|
|
770
|
+
}
|
|
771
|
+
function assertCanonicalPort(port) {
|
|
772
|
+
if (!/^[0-9]+$/.test(port) || port.length > 1 && port.startsWith("0")) {
|
|
773
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
774
|
+
}
|
|
775
|
+
const numericPort = Number(port);
|
|
776
|
+
if (!Number.isSafeInteger(numericPort) || numericPort < 1 || numericPort > 65535) {
|
|
777
|
+
throw new Error("API URL authority must contain a canonical port between 1 and 65535.");
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
function canonicalAuthorityHostname(authority) {
|
|
781
|
+
let rawHostname;
|
|
782
|
+
if (authority.startsWith("[")) {
|
|
783
|
+
const closingBracket = authority.indexOf("]");
|
|
784
|
+
if (closingBracket === -1) {
|
|
785
|
+
throw new Error("API URL authority must contain a canonical hostname.");
|
|
786
|
+
}
|
|
787
|
+
rawHostname = authority.slice(0, closingBracket + 1);
|
|
788
|
+
const portSuffix = authority.slice(closingBracket + 1);
|
|
789
|
+
if (portSuffix) {
|
|
790
|
+
if (!portSuffix.startsWith(":")) {
|
|
791
|
+
throw new Error("API URL authority must contain a canonical hostname and port.");
|
|
792
|
+
}
|
|
793
|
+
assertCanonicalPort(portSuffix.slice(1));
|
|
794
|
+
}
|
|
795
|
+
if (isIP(rawHostname.slice(1, -1)) !== 6) {
|
|
796
|
+
throw new Error("API URL authority must contain a canonical IPv6 literal.");
|
|
797
|
+
}
|
|
798
|
+
} else {
|
|
799
|
+
const firstColon = authority.indexOf(":");
|
|
800
|
+
const lastColon = authority.lastIndexOf(":");
|
|
801
|
+
if (firstColon !== lastColon) {
|
|
802
|
+
throw new Error("IPv6 API URL authorities must use brackets.");
|
|
803
|
+
}
|
|
804
|
+
if (lastColon !== -1) {
|
|
805
|
+
const port = authority.slice(lastColon + 1);
|
|
806
|
+
assertCanonicalPort(port);
|
|
807
|
+
rawHostname = authority.slice(0, lastColon);
|
|
808
|
+
} else {
|
|
809
|
+
rawHostname = authority;
|
|
810
|
+
}
|
|
811
|
+
const ipVersion = isIP(rawHostname);
|
|
812
|
+
const numericAddressParts = rawHostname.split(".");
|
|
813
|
+
const looksLikeNonCanonicalIpv4 = numericAddressParts.every((part) => /^(?:0x[0-9a-f]+|[0-9]+)$/i.test(part));
|
|
814
|
+
if (ipVersion !== 4 && looksLikeNonCanonicalIpv4 || ipVersion !== 4 && !isValidDnsDomain(rawHostname.toLowerCase())) {
|
|
815
|
+
throw new Error("API URL authority must contain a canonical ASCII hostname.");
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return rawHostname.toLowerCase();
|
|
819
|
+
}
|
|
820
|
+
function isDeliberateLoopbackHttpAuthority(authority) {
|
|
821
|
+
return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?$/i.test(authority);
|
|
822
|
+
}
|
|
823
|
+
function toV1BaseUrl(apiUrl) {
|
|
824
|
+
if (ASCII_CONTROL_PATTERN.test(apiUrl)) {
|
|
825
|
+
throw new Error("API URL must not contain ASCII control characters.");
|
|
826
|
+
}
|
|
827
|
+
const input = apiUrl.trim();
|
|
828
|
+
const authority = rawAuthority(input);
|
|
829
|
+
if (authority.includes("@") || authority.includes("\\") || authority.includes("%") || /[^\x00-\x7f]/.test(authority)) {
|
|
830
|
+
throw new Error("API URL authority must be canonical ASCII without credentials.");
|
|
831
|
+
}
|
|
832
|
+
const canonicalHostname = canonicalAuthorityHostname(authority);
|
|
833
|
+
const url = new URL(input);
|
|
834
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
835
|
+
throw new Error("API URL must use http or https.");
|
|
836
|
+
}
|
|
837
|
+
if (url.username || url.password) {
|
|
838
|
+
throw new Error("API URL must not include credentials.");
|
|
839
|
+
}
|
|
840
|
+
if (!url.hostname || url.hostname.endsWith(".")) {
|
|
841
|
+
throw new Error("API URL must include a canonical hostname.");
|
|
842
|
+
}
|
|
843
|
+
if (url.hostname.toLowerCase() !== canonicalHostname) {
|
|
844
|
+
throw new Error("API URL authority must not rely on parser hostname normalization.");
|
|
845
|
+
}
|
|
846
|
+
if (url.hostname.split(".").some((label) => label.toLowerCase().startsWith("xn--"))) {
|
|
847
|
+
throw new Error("API URL must not use IDN or punycode hostnames.");
|
|
848
|
+
}
|
|
849
|
+
if (url.protocol === "http:" && !isDeliberateLoopbackHttpAuthority(authority)) {
|
|
850
|
+
throw new Error("API URL may use http only for an exact loopback authority.");
|
|
851
|
+
}
|
|
852
|
+
if (url.search || url.hash) {
|
|
853
|
+
throw new Error("API URL must not include a query string or fragment.");
|
|
854
|
+
}
|
|
855
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
856
|
+
if (path.endsWith("/v1"))
|
|
857
|
+
path = path.slice(0, -"/v1".length);
|
|
858
|
+
url.pathname = `${path}/v1`;
|
|
859
|
+
return url.toString().replace(/\/+$/, "");
|
|
860
|
+
}
|
|
861
|
+
class ClientTransportConfigurationError extends Error {
|
|
862
|
+
appName;
|
|
863
|
+
sources;
|
|
864
|
+
constructor(appName, message, sources = []) {
|
|
865
|
+
super(message);
|
|
866
|
+
this.name = "ClientTransportConfigurationError";
|
|
867
|
+
this.appName = appName;
|
|
868
|
+
this.sources = Object.freeze([...sources]);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
class ClientCredentialMissingError extends ClientTransportConfigurationError {
|
|
873
|
+
authorityConfigured;
|
|
874
|
+
constructor(name, message, sources, authorityConfigured) {
|
|
875
|
+
super(name, message, sources);
|
|
876
|
+
this.authorityConfigured = authorityConfigured;
|
|
877
|
+
this.name = "ClientCredentialMissingError";
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function resolveClientAuthoritySnapshot(name, env = process.env, options = {}) {
|
|
881
|
+
env = snapshotClientEnvironment(name, env);
|
|
882
|
+
const keys = clientTransportEnvKeys(name);
|
|
883
|
+
const definedUrlEntries = keys.apiUrlKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, raw: String(env[key]) }));
|
|
884
|
+
const blankUrl = definedUrlEntries.find((entry) => entry.raw.trim().length === 0);
|
|
885
|
+
if (blankUrl) {
|
|
886
|
+
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]);
|
|
887
|
+
}
|
|
888
|
+
const controlledUrl = definedUrlEntries.find((entry) => ASCII_CONTROL_PATTERN.test(entry.raw));
|
|
889
|
+
if (controlledUrl) {
|
|
890
|
+
throw new ClientTransportConfigurationError(name, `${controlledUrl.key} contains ASCII control characters.`, [controlledUrl.key]);
|
|
891
|
+
}
|
|
892
|
+
const usableUrlEntries = definedUrlEntries.map((entry) => ({ key: entry.key, value: entry.raw.trim() }));
|
|
893
|
+
const normalizedAuthority = (entry) => {
|
|
894
|
+
try {
|
|
895
|
+
return toV1BaseUrl(entry.value);
|
|
896
|
+
} catch {
|
|
897
|
+
throw new ClientTransportConfigurationError(name, `Invalid API URL from ${entry.key}; use an absolute HTTPS URL or exact loopback HTTP authority without credentials, query or fragment.`, [entry.key]);
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
if (usableUrlEntries.length > 1 && new Set(usableUrlEntries.map(normalizedAuthority)).size > 1) {
|
|
901
|
+
throw new ClientTransportConfigurationError(name, `${usableUrlEntries.map((entry) => entry.key).join(" and ")} disagree; client authority aliases must select the same versioned API authority or only one may be set.`, usableUrlEntries.map((entry) => entry.key));
|
|
902
|
+
}
|
|
903
|
+
const envUrlHit = usableUrlEntries[0] ?? null;
|
|
904
|
+
const keychainUrlHit = keychainConfigValue(name, env, options.credentials?.keychain);
|
|
905
|
+
const profile = options.bindUnpairedCredentialsToFleet ? options.credentials?.profile?.trim() || env.HASNA_PROFILE?.trim() || null : null;
|
|
906
|
+
const diskConfigUrlHit = appConfigDiskValue(name, env, keys.apiUrlKeys, profile);
|
|
907
|
+
if (diskConfigUrlHit?.unusable) {
|
|
908
|
+
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]);
|
|
909
|
+
}
|
|
910
|
+
const urlCandidates = [
|
|
911
|
+
...envUrlHit ? [envUrlHit] : [],
|
|
912
|
+
...keychainUrlHit ? [{ key: keychainUrlHit.source, value: keychainUrlHit.value }] : [],
|
|
913
|
+
...diskConfigUrlHit ? [{ key: diskConfigUrlHit.path, value: diskConfigUrlHit.value.trim() }] : []
|
|
914
|
+
];
|
|
915
|
+
const configuredUrl = urlCandidates[0] ?? null;
|
|
916
|
+
const normalizedUrls = urlCandidates.map(normalizedAuthority);
|
|
917
|
+
const divergentUrls = urlCandidates.filter((_, index) => normalizedUrls[index] !== normalizedUrls[0]);
|
|
918
|
+
if (configuredUrl && divergentUrls.length > 0) {
|
|
919
|
+
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));
|
|
920
|
+
}
|
|
921
|
+
if (configuredUrl && options.bindUnpairedCredentialsToFleet) {
|
|
922
|
+
try {
|
|
923
|
+
toV1BaseUrl(configuredUrl.value);
|
|
924
|
+
} catch (error) {
|
|
925
|
+
throw new ClientTransportConfigurationError(name, `Invalid API URL from ${configuredUrl.key}: ${error instanceof Error ? error.message : String(error)}`, [configuredUrl.key]);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return { keys, configuredUrl, envUrlHit, keychainUrlHit, diskConfigUrlHit };
|
|
929
|
+
}
|
|
930
|
+
function resolveClientTransportSnapshot(name, env = process.env, options = {}) {
|
|
931
|
+
env = snapshotClientEnvironment(name, env);
|
|
932
|
+
const { keys, configuredUrl, envUrlHit, keychainUrlHit, diskConfigUrlHit } = resolveClientAuthoritySnapshot(name, env, options);
|
|
933
|
+
const warnings = [];
|
|
934
|
+
if (configuredUrl && !envUrlHit) {
|
|
935
|
+
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.`);
|
|
936
|
+
}
|
|
937
|
+
const credential = resolveCredential(name, env, options.credentials);
|
|
938
|
+
if (!credential) {
|
|
939
|
+
const diskHint = credentialDiskSourcesForMessage(name, env);
|
|
940
|
+
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`;
|
|
941
|
+
warnings.push(`${lead}; refusing to create an unauthenticated client — 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.`);
|
|
942
|
+
const sources = [configuredUrl?.key ?? keys.apiUrlKeys[0]];
|
|
943
|
+
if (options.bindUnpairedCredentialsToFleet)
|
|
944
|
+
throw new ClientCredentialMissingError(name, warnings.join(" "), sources, configuredUrl !== null);
|
|
945
|
+
throw new ClientTransportConfigurationError(name, warnings.join(" "), sources);
|
|
946
|
+
}
|
|
947
|
+
if (credential.warning)
|
|
948
|
+
warnings.push(credential.warning);
|
|
949
|
+
let urlHit;
|
|
950
|
+
if (configuredUrl) {
|
|
951
|
+
urlHit = configuredUrl;
|
|
952
|
+
} else {
|
|
953
|
+
try {
|
|
954
|
+
urlHit = { key: DEFAULT_AUTHORITY_SOURCE, value: defaultFleetGatewayBaseUrl(name) };
|
|
955
|
+
} catch (error) {
|
|
956
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
957
|
+
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]]);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
const apiUrlSource = urlHit.key;
|
|
961
|
+
let baseUrl;
|
|
962
|
+
try {
|
|
963
|
+
baseUrl = toV1BaseUrl(urlHit.value);
|
|
964
|
+
} catch (error) {
|
|
965
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
966
|
+
throw new ClientTransportConfigurationError(name, `Invalid API URL from ${apiUrlSource}: ${message}`, [apiUrlSource]);
|
|
967
|
+
}
|
|
968
|
+
if (options.bindUnpairedCredentialsToFleet && baseUrl !== toV1BaseUrl(defaultFleetGatewayBaseUrl(name))) {
|
|
969
|
+
const pairedDisk = diskConfigUrlHit?.path === credential.source && (credential.tier === "disk" || credential.tier === "profile" || credential.tier === "pointer");
|
|
970
|
+
const pairedKeychain = credential.tier === "keychain" && keychainUrlHit?.source === credential.source.replace(".api-key@", ".api-url@");
|
|
971
|
+
const deliberatePair = envUrlHit !== null && (credential.tier === "argument" || credential.tier === "override");
|
|
972
|
+
if (!pairedDisk && !pairedKeychain && !deliberatePair) {
|
|
973
|
+
throw new ClientTransportConfigurationError(name, "An unpaired legacy credential is bound to the fleet gateway. Configure a stored authority/credential pair or an explicit API key override for the intended authority; no credential was sent.");
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
resolution: {
|
|
978
|
+
transport: "http",
|
|
979
|
+
transportSource: urlHit.key,
|
|
980
|
+
baseUrl,
|
|
981
|
+
apiUrlSource,
|
|
982
|
+
apiKeyPresent: true,
|
|
983
|
+
apiKeySource: credential.source,
|
|
984
|
+
apiKeyTier: credential.tier,
|
|
985
|
+
misconfigured: false,
|
|
986
|
+
warning: warnings.length > 0 ? warnings.join(" ") : null
|
|
987
|
+
},
|
|
988
|
+
credential
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
function credentialDiskSourcesForMessage(name, env) {
|
|
992
|
+
const paths = credentialDiskSources(name, env);
|
|
993
|
+
return paths.length > 0 ? paths.join(" or ") : "<no HOME or HASNA_HOME set in this environment, so no credential file was consulted>";
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
class HasnaHttpError extends Error {
|
|
997
|
+
status;
|
|
998
|
+
method;
|
|
999
|
+
path;
|
|
1000
|
+
credentialSource;
|
|
1001
|
+
credentialTier;
|
|
1002
|
+
constructor(method, path, status, body, credential) {
|
|
1003
|
+
const guidance = credential ? `. ${credential.guidance}` : "";
|
|
1004
|
+
super(`Hasna cloud request failed: ${method} ${path} -> ${status}${guidance}`);
|
|
1005
|
+
this.name = "HasnaHttpError";
|
|
1006
|
+
this.status = status;
|
|
1007
|
+
this.method = method;
|
|
1008
|
+
this.path = path;
|
|
1009
|
+
Object.defineProperty(this, "body", {
|
|
1010
|
+
value: body,
|
|
1011
|
+
enumerable: status !== 401 && status !== 403,
|
|
1012
|
+
writable: false,
|
|
1013
|
+
configurable: false
|
|
1014
|
+
});
|
|
1015
|
+
this.credentialSource = credential?.source ?? null;
|
|
1016
|
+
this.credentialTier = credential?.tier ?? null;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
function currentCredential(name, apiKey) {
|
|
1020
|
+
if (typeof apiKey === "function") {
|
|
1021
|
+
return validateAndSealResolvedCredential(name, apiKey());
|
|
1022
|
+
}
|
|
1023
|
+
return explicitCredential(name, apiKey);
|
|
1024
|
+
}
|
|
1025
|
+
async function resolveRequestCredential(name, apiKey, env = process.env) {
|
|
1026
|
+
const resolved = currentCredential(name, apiKey);
|
|
1027
|
+
if (resolved.tier === "pointer") {
|
|
1028
|
+
return completePointerCredential(name, resolved, env);
|
|
1029
|
+
}
|
|
1030
|
+
return resolved;
|
|
1031
|
+
}
|
|
1032
|
+
function authFailureGuidance(credential) {
|
|
1033
|
+
const origin = `The API key for this request came from ${credential.source}`;
|
|
1034
|
+
if (credential.deliberate) {
|
|
1035
|
+
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.`;
|
|
1036
|
+
return `${origin} — 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}`;
|
|
1037
|
+
}
|
|
1038
|
+
if (credential.tier === "env") {
|
|
1039
|
+
const target = credential.diskCandidates[0];
|
|
1040
|
+
const remedy = target ? `Store the CURRENT key in the Keychain or write it to ${target} — 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.`;
|
|
1041
|
+
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 — 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}`;
|
|
1042
|
+
}
|
|
1043
|
+
if (credential.tier === "keychain") {
|
|
1044
|
+
return `${origin}, which was re-read from the Keychain on this very call — 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.`;
|
|
1045
|
+
}
|
|
1046
|
+
return `${origin}, which was re-read from disk on this very call — 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.`;
|
|
1047
|
+
}
|
|
1048
|
+
var DEFAULT_RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
1049
|
+
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
1050
|
+
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
1051
|
+
"host",
|
|
1052
|
+
":authority",
|
|
1053
|
+
"forwarded",
|
|
1054
|
+
"x-forwarded-host",
|
|
1055
|
+
"x-original-host"
|
|
1056
|
+
]);
|
|
1057
|
+
function assertNoAuthorityOverrideHeaders(headers, source) {
|
|
1058
|
+
if (!headers)
|
|
1059
|
+
return;
|
|
1060
|
+
const forbidden = Object.keys(headers).find((name) => AUTHORITY_OVERRIDE_HEADERS.has(name.trim().toLowerCase()));
|
|
1061
|
+
if (forbidden) {
|
|
1062
|
+
throw new Error(`Authenticated ${source} headers must not set authority header '${forbidden}'.`);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
function appendQuery(path, query) {
|
|
1066
|
+
if (!query)
|
|
1067
|
+
return path;
|
|
1068
|
+
const params = query instanceof URLSearchParams ? query : new URLSearchParams;
|
|
1069
|
+
if (!(query instanceof URLSearchParams)) {
|
|
1070
|
+
for (const [key, value] of Object.entries(query)) {
|
|
1071
|
+
if (value === null || value === undefined)
|
|
1072
|
+
continue;
|
|
1073
|
+
if (Array.isArray(value)) {
|
|
1074
|
+
for (const v of value)
|
|
1075
|
+
params.append(key, String(v));
|
|
1076
|
+
} else {
|
|
1077
|
+
params.append(key, String(value));
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
const qs = params.toString();
|
|
1082
|
+
if (!qs)
|
|
1083
|
+
return path;
|
|
1084
|
+
return `${path}${path.includes("?") ? "&" : "?"}${qs}`;
|
|
1085
|
+
}
|
|
1086
|
+
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1087
|
+
function createHasnaHttpTransportInternal(options, requestBindingProvider) {
|
|
1088
|
+
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
1089
|
+
const base = toV1BaseUrl(options.baseUrl);
|
|
1090
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
1091
|
+
const sleep = options.sleepImpl ?? defaultSleep;
|
|
1092
|
+
const defaultRetry = options.retry;
|
|
1093
|
+
function resolveRetry(callRetry) {
|
|
1094
|
+
const chosen = callRetry !== undefined ? callRetry : defaultRetry;
|
|
1095
|
+
if (chosen === false)
|
|
1096
|
+
return null;
|
|
1097
|
+
const r2 = chosen ?? {};
|
|
1098
|
+
return {
|
|
1099
|
+
retries: r2.retries ?? 2,
|
|
1100
|
+
baseDelayMs: r2.baseDelayMs ?? 200,
|
|
1101
|
+
maxDelayMs: r2.maxDelayMs ?? 2000,
|
|
1102
|
+
retryStatuses: r2.retryStatuses ?? [...DEFAULT_RETRY_STATUSES]
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
async function once(method, rel, body, opts, bind) {
|
|
1106
|
+
const materializeHeaders = (input) => Object.fromEntries(Object.entries(input ?? {}).map(([key, value]) => [key, String(value)]));
|
|
1107
|
+
const transportHeaders = materializeHeaders(options.headers);
|
|
1108
|
+
const requestHeaders = materializeHeaders(opts.headers);
|
|
1109
|
+
assertNoAuthorityOverrideHeaders(transportHeaders, "transport");
|
|
1110
|
+
assertNoAuthorityOverrideHeaders(requestHeaders, "request");
|
|
1111
|
+
const headers = {
|
|
1112
|
+
Accept: "application/json",
|
|
1113
|
+
...transportHeaders,
|
|
1114
|
+
...requestHeaders
|
|
1115
|
+
};
|
|
1116
|
+
const idempotencyKey = opts.idempotencyKey;
|
|
1117
|
+
if (idempotencyKey)
|
|
1118
|
+
headers["Idempotency-Key"] = String(idempotencyKey);
|
|
1119
|
+
const init = {
|
|
1120
|
+
method,
|
|
1121
|
+
headers,
|
|
1122
|
+
redirect: "manual"
|
|
1123
|
+
};
|
|
1124
|
+
if (body !== undefined) {
|
|
1125
|
+
headers["Content-Type"] = "application/json";
|
|
1126
|
+
init.body = JSON.stringify(body);
|
|
1127
|
+
}
|
|
1128
|
+
const controller = new AbortController;
|
|
1129
|
+
const onAbort = () => controller.abort();
|
|
1130
|
+
const callerSignal = opts.signal;
|
|
1131
|
+
if (callerSignal) {
|
|
1132
|
+
if (callerSignal.aborted)
|
|
1133
|
+
controller.abort();
|
|
1134
|
+
else
|
|
1135
|
+
callerSignal.addEventListener("abort", onAbort, { once: true });
|
|
1136
|
+
}
|
|
1137
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs);
|
|
1138
|
+
init.signal = controller.signal;
|
|
1139
|
+
let response;
|
|
1140
|
+
let credential;
|
|
1141
|
+
try {
|
|
1142
|
+
const binding = bind();
|
|
1143
|
+
credential = binding.credential;
|
|
1144
|
+
init.headers = {
|
|
1145
|
+
"x-api-key": credential.apiKey,
|
|
1146
|
+
Authorization: `Bearer ${credential.apiKey}`,
|
|
1147
|
+
...headers
|
|
1148
|
+
};
|
|
1149
|
+
try {
|
|
1150
|
+
response = await fetchImpl(`${binding.baseUrl}${rel}`, init);
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1153
|
+
if (callerSignal?.aborted)
|
|
1154
|
+
return { ok: false, retryable: false, error: err };
|
|
1155
|
+
return { ok: false, retryable: true, error: err };
|
|
1156
|
+
}
|
|
1157
|
+
} finally {
|
|
1158
|
+
clearTimeout(timer);
|
|
1159
|
+
if (callerSignal)
|
|
1160
|
+
callerSignal.removeEventListener("abort", onAbort);
|
|
1161
|
+
}
|
|
1162
|
+
const authenticationFailure = response.status === 401 || response.status === 403;
|
|
1163
|
+
let parsed = undefined;
|
|
1164
|
+
if (authenticationFailure) {
|
|
1165
|
+
try {
|
|
1166
|
+
await response.body?.cancel();
|
|
1167
|
+
} catch {}
|
|
1168
|
+
} else {
|
|
1169
|
+
const text = await response.text();
|
|
1170
|
+
if (text.length > 0) {
|
|
1171
|
+
try {
|
|
1172
|
+
parsed = JSON.parse(text);
|
|
1173
|
+
} catch {
|
|
1174
|
+
parsed = text;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
if (!response.ok) {
|
|
1179
|
+
if (response.status >= 300 && response.status < 400) {
|
|
1180
|
+
return {
|
|
1181
|
+
ok: false,
|
|
1182
|
+
retryable: false,
|
|
1183
|
+
error: new HasnaHttpError(method, rel, response.status, parsed)
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
if (authenticationFailure) {
|
|
1187
|
+
return {
|
|
1188
|
+
ok: false,
|
|
1189
|
+
retryable: false,
|
|
1190
|
+
error: new HasnaHttpError(method, rel, response.status, undefined, {
|
|
1191
|
+
source: credential.source,
|
|
1192
|
+
tier: credential.tier,
|
|
1193
|
+
guidance: authFailureGuidance(credential)
|
|
1194
|
+
})
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
const retry = resolveRetry(opts.retry);
|
|
1198
|
+
const retryable = retry ? retry.retryStatuses.includes(response.status) : false;
|
|
1199
|
+
return { ok: false, retryable, error: new HasnaHttpError(method, rel, response.status, parsed) };
|
|
1200
|
+
}
|
|
1201
|
+
return { ok: true, value: parsed };
|
|
1202
|
+
}
|
|
1203
|
+
async function request(method, path, body, opts = {}) {
|
|
1204
|
+
const upper = method.toUpperCase();
|
|
1205
|
+
const rel = appendQuery(path.startsWith("/") ? path : `/${path}`, opts.query);
|
|
1206
|
+
const retry = resolveRetry(opts.retry);
|
|
1207
|
+
const methodRetryable = IDEMPOTENT_METHODS.has(upper) || Boolean(opts.idempotencyKey);
|
|
1208
|
+
const maxAttempts = retry && methodRetryable ? retry.retries + 1 : 1;
|
|
1209
|
+
let finalize;
|
|
1210
|
+
if (requestBindingProvider) {
|
|
1211
|
+
const prepared = requestBindingProvider();
|
|
1212
|
+
finalize = typeof prepared === "function" ? prepared : await prepared;
|
|
1213
|
+
} else {
|
|
1214
|
+
const credential = await resolveRequestCredential(options.name, options.apiKey);
|
|
1215
|
+
finalize = () => ({ baseUrl: base, credential });
|
|
1216
|
+
}
|
|
1217
|
+
let binding;
|
|
1218
|
+
const bind = () => binding ??= finalize();
|
|
1219
|
+
let last = null;
|
|
1220
|
+
for (let attempt = 1;attempt <= maxAttempts; attempt++) {
|
|
1221
|
+
const result = await once(upper, rel, body, opts, bind);
|
|
1222
|
+
if (result.ok)
|
|
1223
|
+
return result.value;
|
|
1224
|
+
last = result;
|
|
1225
|
+
const canRetry = retry !== null && methodRetryable && result.retryable && attempt < maxAttempts;
|
|
1226
|
+
if (!canRetry)
|
|
1227
|
+
break;
|
|
1228
|
+
const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
|
|
1229
|
+
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
1230
|
+
await sleep(backoff + jitter);
|
|
1231
|
+
}
|
|
1232
|
+
throw last.error;
|
|
1233
|
+
}
|
|
1234
|
+
return {
|
|
1235
|
+
baseUrl: base,
|
|
1236
|
+
request,
|
|
1237
|
+
get: (path, opts) => request("GET", path, undefined, opts),
|
|
1238
|
+
post: (path, body, opts) => request("POST", path, body, opts),
|
|
1239
|
+
put: (path, body, opts) => request("PUT", path, body, opts),
|
|
1240
|
+
patch: (path, body, opts) => request("PATCH", path, body, opts),
|
|
1241
|
+
del: (path, body, opts) => request("DELETE", path, body, opts)
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
function createClientTransport(name, env = process.env, overrides) {
|
|
1245
|
+
const credentialOptions = overrides?.credentials;
|
|
1246
|
+
const snapshotOptions = {
|
|
1247
|
+
...credentialOptions ? { credentials: credentialOptions } : {},
|
|
1248
|
+
...overrides?.bindUnpairedCredentialsToFleet === undefined ? {} : { bindUnpairedCredentialsToFleet: overrides.bindUnpairedCredentialsToFleet }
|
|
1249
|
+
};
|
|
1250
|
+
const resolution = resolveClientTransportSnapshot(name, env, snapshotOptions).resolution;
|
|
1251
|
+
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;
|
|
1252
|
+
const unstableConfiguration = () => new ClientTransportConfigurationError(name, "The configured service authority or credential changed while a request was being prepared; no authenticated request was sent.");
|
|
1253
|
+
const assertPinnedAuthority = (snapshot) => {
|
|
1254
|
+
if (snapshot.resolution.baseUrl !== resolution.baseUrl) {
|
|
1255
|
+
throw new ClientTransportConfigurationError(name, "The configured service authority changed; rebuild the client before sending credentials.");
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
const requestBindingProvider = () => {
|
|
1259
|
+
const first = resolveClientTransportSnapshot(name, env, snapshotOptions);
|
|
1260
|
+
assertPinnedAuthority(first);
|
|
1261
|
+
const finalize = (reviewed2, completed) => () => {
|
|
1262
|
+
const final = resolveClientTransportSnapshot(name, env, snapshotOptions);
|
|
1263
|
+
if (!sameBinding(reviewed2, final))
|
|
1264
|
+
throw unstableConfiguration();
|
|
1265
|
+
assertPinnedAuthority(final);
|
|
1266
|
+
return {
|
|
1267
|
+
baseUrl: final.resolution.baseUrl,
|
|
1268
|
+
credential: completed ?? currentCredential(name, () => final.credential)
|
|
1269
|
+
};
|
|
1270
|
+
};
|
|
1271
|
+
if (first.credential.tier !== "pointer")
|
|
1272
|
+
return finalize(first);
|
|
1273
|
+
const reviewed = resolveClientTransportSnapshot(name, env, snapshotOptions);
|
|
1274
|
+
if (!sameBinding(first, reviewed))
|
|
1275
|
+
throw unstableConfiguration();
|
|
1276
|
+
assertPinnedAuthority(reviewed);
|
|
1277
|
+
return resolveRequestCredential(name, () => reviewed.credential, env).then((credential) => finalize(reviewed, credential));
|
|
1278
|
+
};
|
|
1279
|
+
return {
|
|
1280
|
+
transport: "http",
|
|
1281
|
+
client: createHasnaHttpTransportInternal({
|
|
1282
|
+
name,
|
|
1283
|
+
baseUrl: resolution.baseUrl,
|
|
1284
|
+
apiKey: () => {
|
|
1285
|
+
throw new Error("The authenticated request binding provider was not invoked.");
|
|
1286
|
+
},
|
|
1287
|
+
...overrides?.fetchImpl ? { fetchImpl: overrides.fetchImpl } : {},
|
|
1288
|
+
...overrides?.headers ? { headers: overrides.headers } : {},
|
|
1289
|
+
...overrides?.timeoutMs ? { timeoutMs: overrides.timeoutMs } : {},
|
|
1290
|
+
...overrides?.retry !== undefined ? { retry: overrides.retry } : {},
|
|
1291
|
+
...overrides?.sleepImpl ? { sleepImpl: overrides.sleepImpl } : {}
|
|
1292
|
+
}, requestBindingProvider),
|
|
1293
|
+
resolution
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// src/sdk/generated-api.ts
|
|
1298
|
+
class WorkflowsApi {
|
|
1299
|
+
async inspectDaemon() {
|
|
1300
|
+
return this.apiRequest("GET", `/daemon/status`, {
|
|
1301
|
+
body: undefined,
|
|
1302
|
+
query: undefined
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
async getHealth() {
|
|
1306
|
+
return this.apiRequest("GET", `/health`, {
|
|
1307
|
+
body: undefined,
|
|
1308
|
+
query: undefined
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
async listLanes() {
|
|
1312
|
+
return this.apiRequest("GET", `/lanes`, {
|
|
1313
|
+
body: undefined,
|
|
1314
|
+
query: undefined
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
async probeLane(lane) {
|
|
1318
|
+
return this.apiRequest("GET", `/lanes/${encodeURIComponent(String(lane))}`, {
|
|
1319
|
+
body: undefined,
|
|
1320
|
+
query: undefined
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
async listServerMachines() {
|
|
1324
|
+
return this.apiRequest("GET", `/machines/list`, {
|
|
1325
|
+
body: undefined,
|
|
1326
|
+
query: undefined
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
async inspectServerMachine() {
|
|
1330
|
+
return this.apiRequest("GET", `/machines/status`, {
|
|
1331
|
+
body: undefined,
|
|
1332
|
+
query: undefined
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
async listMemoRecords() {
|
|
1336
|
+
return this.apiRequest("GET", `/memos/list`, {
|
|
1337
|
+
body: undefined,
|
|
1338
|
+
query: undefined
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
async getReadiness() {
|
|
1342
|
+
return this.apiRequest("GET", `/ready`, {
|
|
1343
|
+
body: undefined,
|
|
1344
|
+
query: undefined
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
async listRunRecords(query) {
|
|
1348
|
+
return this.apiRequest("GET", `/runs`, {
|
|
1349
|
+
body: undefined,
|
|
1350
|
+
query
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
async getRunRecord(id) {
|
|
1354
|
+
return this.apiRequest("GET", `/runs/${encodeURIComponent(String(id))}`, {
|
|
1355
|
+
body: undefined,
|
|
1356
|
+
query: undefined
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
async cancelWorkflow(id) {
|
|
1360
|
+
return this.apiRequest("POST", `/runs/${encodeURIComponent(String(id))}/cancel`, {
|
|
1361
|
+
body: undefined,
|
|
1362
|
+
query: undefined
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
async getRunEvents(id) {
|
|
1366
|
+
return this.apiRequest("GET", `/runs/${encodeURIComponent(String(id))}/events`, {
|
|
1367
|
+
body: undefined,
|
|
1368
|
+
query: undefined
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
async getRunNodes(id) {
|
|
1372
|
+
return this.apiRequest("GET", `/runs/${encodeURIComponent(String(id))}/nodes`, {
|
|
1373
|
+
body: undefined,
|
|
1374
|
+
query: undefined
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
async resumeWorkflow(id) {
|
|
1378
|
+
return this.apiRequest("POST", `/runs/${encodeURIComponent(String(id))}/resume`, {
|
|
1379
|
+
body: undefined,
|
|
1380
|
+
query: undefined
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
async listSessionEvents() {
|
|
1384
|
+
return this.apiRequest("GET", `/sessions/list`, {
|
|
1385
|
+
body: undefined,
|
|
1386
|
+
query: undefined
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
async inspectSession() {
|
|
1390
|
+
return this.apiRequest("GET", `/sessions/pull`, {
|
|
1391
|
+
body: undefined,
|
|
1392
|
+
query: undefined
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
async triggerWorkflow(body) {
|
|
1396
|
+
return this.apiRequest("POST", `/trigger`, {
|
|
1397
|
+
body,
|
|
1398
|
+
query: undefined
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
async validateWorkflow(body) {
|
|
1402
|
+
return this.apiRequest("POST", `/validate`, {
|
|
1403
|
+
body,
|
|
1404
|
+
query: undefined
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
async getVersion() {
|
|
1408
|
+
return this.apiRequest("GET", `/version`, {
|
|
1409
|
+
body: undefined,
|
|
1410
|
+
query: undefined
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// src/identity.ts
|
|
1416
|
+
import { createHash } from "node:crypto";
|
|
1417
|
+
import { hostname } from "node:os";
|
|
1418
|
+
|
|
1419
|
+
// src/client-error.ts
|
|
1420
|
+
class WorkflowInputError extends Error {
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// src/identity.ts
|
|
1424
|
+
function name(value) {
|
|
1425
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(value) || /^(owner|andrei-hasna)$/i.test(value)) {
|
|
1426
|
+
throw new WorkflowInputError("agent must be a non-owner identity with 1 to 128 identity characters");
|
|
1427
|
+
}
|
|
1428
|
+
return value;
|
|
1429
|
+
}
|
|
1430
|
+
function station(env) {
|
|
1431
|
+
const value = (env.HASNA_STATION ?? hostname()).split(".")[0].toLowerCase();
|
|
1432
|
+
return value.replace(/[^a-z0-9-]/g, "-").slice(0, 63) || "unknown";
|
|
1433
|
+
}
|
|
1434
|
+
function workflowAgent(env = process.env, explicit) {
|
|
1435
|
+
if (explicit !== undefined)
|
|
1436
|
+
return { name: name(explicit), source: "argument" };
|
|
1437
|
+
if (env.HASNA_WORKFLOWS_AGENT !== undefined)
|
|
1438
|
+
return { name: name(env.HASNA_WORKFLOWS_AGENT), source: "HASNA_WORKFLOWS_AGENT" };
|
|
1439
|
+
for (const [harness, key] of [["claude", "CLAUDE_CODE_SESSION_ID"], ["codex", "CODEX_THREAD_ID"]]) {
|
|
1440
|
+
if (env[key] !== undefined)
|
|
1441
|
+
return {
|
|
1442
|
+
name: `${harness}-${createHash("sha256").update(JSON.stringify([env[key], null])).digest("hex").slice(0, 32)}`,
|
|
1443
|
+
source: key
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
return { name: `cli@${station(env)}`, source: "station" };
|
|
1447
|
+
}
|
|
1448
|
+
// package.json
|
|
1449
|
+
var package_default = {
|
|
1450
|
+
name: "@hasna/workflows",
|
|
1451
|
+
version: "0.2.1",
|
|
1452
|
+
description: "Universal graph workflow app — graph language (start/step/decision/while/end), three-table store, session WAL, daemon with claims/leases, four lane adapters (claude/codex/cursor/grok), and a full CLI on four surfaces",
|
|
1453
|
+
type: "module",
|
|
1454
|
+
main: "./dist/index.js",
|
|
1455
|
+
types: "./types/index.d.ts",
|
|
1456
|
+
bin: {
|
|
1457
|
+
workflows: "dist/cli/index.js",
|
|
1458
|
+
"workflows-mcp": "dist/mcp/index.js",
|
|
1459
|
+
"workflows-serve": "dist/serve/index.js"
|
|
1460
|
+
},
|
|
1461
|
+
exports: {
|
|
1462
|
+
".": {
|
|
1463
|
+
types: "./types/index.d.ts",
|
|
1464
|
+
import: "./dist/index.js"
|
|
1465
|
+
},
|
|
1466
|
+
"./sdk": {
|
|
1467
|
+
types: "./types/sdk.d.ts",
|
|
1468
|
+
import: "./dist/sdk.js"
|
|
1469
|
+
},
|
|
1470
|
+
"./cli": {
|
|
1471
|
+
types: "./types/cli/index.d.ts",
|
|
1472
|
+
import: "./dist/cli/index.js"
|
|
1473
|
+
}
|
|
1474
|
+
},
|
|
1475
|
+
files: [
|
|
1476
|
+
"dist",
|
|
1477
|
+
"types",
|
|
1478
|
+
"README.md",
|
|
1479
|
+
"LICENSE",
|
|
1480
|
+
"hasna.contract.json"
|
|
1481
|
+
],
|
|
1482
|
+
scripts: {
|
|
1483
|
+
build: "bun run check:sdk && bun run build:runtime && bun run build:types",
|
|
1484
|
+
"generate:sdk": "bun scripts/generate-sdk.ts --write",
|
|
1485
|
+
"check:sdk": "bun scripts/generate-sdk.ts",
|
|
1486
|
+
"test:artifacts": "bun scripts/artifact-acceptance.ts",
|
|
1487
|
+
"build:runtime": "bun build src/cli/index.ts --outdir dist/cli --target bun && bun build src/mcp/index.ts --outdir dist/mcp --target bun && bun build src/serve/index.ts src/serve/admin.ts --outdir dist/serve --target bun --packages external && bun build src/index.ts src/service.ts src/handlers.ts src/agent-tools.ts src/types.ts --root src --outdir dist --target bun --packages external && bun build src/sdk.ts --outdir dist --target node",
|
|
1488
|
+
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly --outDir types",
|
|
1489
|
+
"artifact-scan": "bun scripts/contracts-cli.mjs artifact-scan dist",
|
|
1490
|
+
"contract:check": "bun scripts/contracts-cli.mjs repo-conformance .",
|
|
1491
|
+
typecheck: "tsc --noEmit",
|
|
1492
|
+
test: "bun test",
|
|
1493
|
+
"test:pg": 'WORKFLOWS_TEST_DATABASE_URL="${WORKFLOWS_TEST_DATABASE_URL:?point it at a fresh test Postgres database}" WORKFLOWS_REQUIRE_POSTGRES=1 bun test src/db/postgres-store.test.ts src/db/postgres-http.test.ts src/db/postgres-fencing.test.ts',
|
|
1494
|
+
prepack: "bun run build && bun run artifact-scan",
|
|
1495
|
+
prepublishOnly: "bun run test && bun run build"
|
|
1496
|
+
},
|
|
1497
|
+
keywords: [
|
|
1498
|
+
"workflows",
|
|
1499
|
+
"graph",
|
|
1500
|
+
"automation",
|
|
1501
|
+
"cli",
|
|
1502
|
+
"typescript",
|
|
1503
|
+
"bun",
|
|
1504
|
+
"hasna"
|
|
1505
|
+
],
|
|
1506
|
+
publishConfig: {
|
|
1507
|
+
registry: "https://registry.npmjs.org",
|
|
1508
|
+
access: "public"
|
|
1509
|
+
},
|
|
1510
|
+
repository: {
|
|
1511
|
+
type: "git",
|
|
1512
|
+
url: "https://github.com/hasna/apps.git",
|
|
1513
|
+
directory: "apps/workflows"
|
|
1514
|
+
},
|
|
1515
|
+
homepage: "https://github.com/hasna/apps/tree/main/apps/workflows",
|
|
1516
|
+
bugs: {
|
|
1517
|
+
url: "https://github.com/hasna/apps/issues"
|
|
1518
|
+
},
|
|
1519
|
+
engines: {
|
|
1520
|
+
bun: ">=1.0.0"
|
|
1521
|
+
},
|
|
1522
|
+
author: "Hasna",
|
|
1523
|
+
license: "Apache-2.0",
|
|
1524
|
+
dependencies: {
|
|
1525
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.234",
|
|
1526
|
+
"@cursor/sdk": "^1.0.28",
|
|
1527
|
+
"@hasna/contracts": "1.3.3",
|
|
1528
|
+
"@openai/codex-sdk": "0.160.0",
|
|
1529
|
+
commander: "^13.1.0",
|
|
1530
|
+
pg: "^8.16.3"
|
|
1531
|
+
},
|
|
1532
|
+
devDependencies: {
|
|
1533
|
+
"@types/bun": "1.3.14",
|
|
1534
|
+
"@types/pg": "^8.15.6",
|
|
1535
|
+
typescript: "^5.7.3"
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
|
|
1539
|
+
// src/version.ts
|
|
1540
|
+
function packageVersion() {
|
|
1541
|
+
return package_default.version;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// src/client.ts
|
|
1545
|
+
class WorkflowsClient extends WorkflowsApi {
|
|
1546
|
+
name = "workflows";
|
|
1547
|
+
resolution;
|
|
1548
|
+
http;
|
|
1549
|
+
constructor(options = {}) {
|
|
1550
|
+
super();
|
|
1551
|
+
const env = { ...options.env ?? process.env };
|
|
1552
|
+
if (options.apiUrl !== undefined) {
|
|
1553
|
+
env.HASNA_WORKFLOWS_API_URL = options.apiUrl;
|
|
1554
|
+
delete env.WORKFLOWS_API_URL;
|
|
1555
|
+
}
|
|
1556
|
+
const { client, resolution } = createClientTransport("workflows", env, {
|
|
1557
|
+
credentials: { keychain: { enabled: options.env === undefined }, ...options.apiKey === undefined ? {} : { apiKey: options.apiKey } },
|
|
1558
|
+
bindUnpairedCredentialsToFleet: true,
|
|
1559
|
+
fetchImpl: options.fetchImpl,
|
|
1560
|
+
timeoutMs: options.timeoutMs ?? 120000,
|
|
1561
|
+
retry: false,
|
|
1562
|
+
headers: { "user-agent": `hasna-workflows-sdk/${packageVersion()}`, "x-hasna-agent": workflowAgent(env, options.agent).name }
|
|
1563
|
+
});
|
|
1564
|
+
this.http = client;
|
|
1565
|
+
this.resolution = resolution;
|
|
1566
|
+
}
|
|
1567
|
+
request(method, path, body) {
|
|
1568
|
+
return this.http.request(method, path, body);
|
|
1569
|
+
}
|
|
1570
|
+
apiRequest(method, path, opts) {
|
|
1571
|
+
const query = new URLSearchParams;
|
|
1572
|
+
for (const [key, value] of Object.entries(opts.query ?? {}))
|
|
1573
|
+
if (value !== undefined && value !== null)
|
|
1574
|
+
query.set(key, String(value));
|
|
1575
|
+
return this.request(method, `${path}${query.size ? `?${query}` : ""}`, opts.body);
|
|
1576
|
+
}
|
|
1577
|
+
health() {
|
|
1578
|
+
return this.getHealth();
|
|
1579
|
+
}
|
|
1580
|
+
ready() {
|
|
1581
|
+
return this.getReadiness();
|
|
1582
|
+
}
|
|
1583
|
+
version() {
|
|
1584
|
+
return this.getVersion();
|
|
1585
|
+
}
|
|
1586
|
+
validate(graph) {
|
|
1587
|
+
return this.validateWorkflow({ graph });
|
|
1588
|
+
}
|
|
1589
|
+
triggerRun(graph, context = {}, opts = {}) {
|
|
1590
|
+
return this.triggerWorkflow({ graph, context, ...opts });
|
|
1591
|
+
}
|
|
1592
|
+
listRuns(options = {}) {
|
|
1593
|
+
return this.listRunRecords(options);
|
|
1594
|
+
}
|
|
1595
|
+
getRun(id) {
|
|
1596
|
+
return this.getRunRecord(id);
|
|
1597
|
+
}
|
|
1598
|
+
listNodes(id) {
|
|
1599
|
+
return this.getRunNodes(id);
|
|
1600
|
+
}
|
|
1601
|
+
events(id) {
|
|
1602
|
+
return this.getRunEvents(id);
|
|
1603
|
+
}
|
|
1604
|
+
cancelRun(id) {
|
|
1605
|
+
return this.cancelWorkflow(id);
|
|
1606
|
+
}
|
|
1607
|
+
resumeRun(id) {
|
|
1608
|
+
return this.resumeWorkflow(id);
|
|
1609
|
+
}
|
|
1610
|
+
lanes() {
|
|
1611
|
+
return this.listLanes();
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
function createWorkflowsClient(options = {}) {
|
|
1615
|
+
return new WorkflowsClient(options);
|
|
1616
|
+
}
|
|
1617
|
+
export {
|
|
1618
|
+
createWorkflowsClient,
|
|
1619
|
+
WorkflowsClient
|
|
1620
|
+
};
|