@hue-run/sdk 0.3.2 → 0.4.2
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/CLI.md +270 -47
- package/ENVIRONMENTS.md +11 -1
- package/README.md +19 -3
- package/dist/client.d.ts +5 -5
- package/dist/client.js +13 -6
- package/dist/environment/tools.d.ts +6 -1
- package/dist/environment/tools.js +7 -1
- package/dist/environment/types.d.ts +6 -1
- package/dist/evals/simulation.d.ts +12 -4
- package/dist/evals/simulation.js +34 -24
- package/dist/evals.d.ts +1 -1
- package/dist/receipt.js +36 -8
- package/dist/setup/application.d.ts +74 -0
- package/dist/setup/application.js +766 -0
- package/dist/setup/backend.d.ts +229 -0
- package/dist/setup/backend.js +855 -0
- package/dist/setup/checkpoint.js +100 -30
- package/dist/setup/cli.js +20 -4
- package/dist/setup/configure.d.ts +13 -0
- package/dist/setup/configure.js +454 -0
- package/dist/setup/credential.d.ts +2 -0
- package/dist/setup/credential.js +9 -0
- package/dist/setup/detect.js +4 -1
- package/dist/setup/installation.d.ts +118 -0
- package/dist/setup/installation.js +605 -0
- package/dist/setup/lock.d.ts +2 -0
- package/dist/setup/lock.js +38 -0
- package/dist/setup/machine.d.ts +1 -10
- package/dist/setup/machine.js +8 -7
- package/dist/setup/render.d.ts +3 -1
- package/dist/setup/render.js +209 -6
- package/dist/setup/runner.d.ts +26 -76
- package/dist/setup/runner.js +320 -45
- package/dist/setup/socket.d.ts +7 -0
- package/dist/setup/socket.js +144 -0
- package/dist/setup/source.d.ts +9 -0
- package/dist/setup/source.js +269 -0
- package/dist/setup/types.d.ts +16 -9
- package/dist/setup/types.js +1 -1
- package/dist/setup.d.ts +6 -2
- package/dist/setup.js +3 -0
- package/dist/types.d.ts +24 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/setup-events.schema.json +16 -9
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { link, lstat, open, readdir, realpath, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { setupManagedDigest, } from "./installation.js";
|
|
6
|
+
async function cleanupTemporary(path) {
|
|
7
|
+
try {
|
|
8
|
+
const info = await lstat(path);
|
|
9
|
+
if (info.isFile())
|
|
10
|
+
await unlink(path);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code !== "ENOENT")
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function inside(parent, child) {
|
|
18
|
+
const path = relative(parent, child);
|
|
19
|
+
return path === "" || (!path.startsWith("..") && !path.startsWith("/"));
|
|
20
|
+
}
|
|
21
|
+
async function safeDirectory(path) {
|
|
22
|
+
const paths = [];
|
|
23
|
+
let current = resolve(path);
|
|
24
|
+
for (;;) {
|
|
25
|
+
paths.unshift(current);
|
|
26
|
+
const parent = dirname(current);
|
|
27
|
+
if (parent === current)
|
|
28
|
+
break;
|
|
29
|
+
current = parent;
|
|
30
|
+
}
|
|
31
|
+
for (const entry of paths) {
|
|
32
|
+
const info = await lstat(entry);
|
|
33
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
34
|
+
throw new Error("Refusing a setup configuration path with a symlink ancestor");
|
|
35
|
+
}
|
|
36
|
+
if ((await realpath(path)) !== resolve(path))
|
|
37
|
+
throw new Error("Refusing a setup configuration path outside its actual directory");
|
|
38
|
+
}
|
|
39
|
+
async function readManaged(path) {
|
|
40
|
+
await safeDirectory(dirname(path));
|
|
41
|
+
let handle;
|
|
42
|
+
try {
|
|
43
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === "ENOENT")
|
|
47
|
+
return undefined;
|
|
48
|
+
throw new Error("Refusing unsafe setup configuration");
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const info = await handle.stat();
|
|
52
|
+
if (!info.isFile() || info.size > 1024 * 1024 || (info.mode & 0o7000) !== 0)
|
|
53
|
+
throw new Error("Refusing unsafe setup configuration");
|
|
54
|
+
const contents = await handle.readFile("utf8");
|
|
55
|
+
const after = await handle.stat();
|
|
56
|
+
if (info.mtimeMs !== after.mtimeMs ||
|
|
57
|
+
info.ctimeMs !== after.ctimeMs ||
|
|
58
|
+
info.size !== after.size)
|
|
59
|
+
throw new Error("Refusing setup configuration changed while reading");
|
|
60
|
+
return { contents, info };
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await handle.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function sameSnapshot(left, right) {
|
|
67
|
+
if (!left || !right)
|
|
68
|
+
return left === right;
|
|
69
|
+
return (left.contents === right.contents &&
|
|
70
|
+
left.info.dev === right.info.dev &&
|
|
71
|
+
left.info.ino === right.info.ino &&
|
|
72
|
+
left.info.mode === right.info.mode &&
|
|
73
|
+
left.info.mtimeMs === right.info.mtimeMs &&
|
|
74
|
+
left.info.ctimeMs === right.info.ctimeMs);
|
|
75
|
+
}
|
|
76
|
+
function serviceName(root) {
|
|
77
|
+
const normalized = basename(root).replace(/[^A-Za-z0-9_.-]+/gu, "-");
|
|
78
|
+
let start = 0;
|
|
79
|
+
let end = normalized.length;
|
|
80
|
+
while (normalized[start] === "-")
|
|
81
|
+
start += 1;
|
|
82
|
+
while (end > start && normalized[end - 1] === "-")
|
|
83
|
+
end -= 1;
|
|
84
|
+
const value = normalized.slice(start, end).slice(0, 220);
|
|
85
|
+
return value ? `hue-setup-${value}` : "hue-setup-project";
|
|
86
|
+
}
|
|
87
|
+
function typescriptConfig(store) {
|
|
88
|
+
return `// Managed by Hue setup. This file contains no credential.
|
|
89
|
+
import { closeSync, constants, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
90
|
+
import { fileURLToPath } from "node:url";
|
|
91
|
+
import { createHmac } from "node:crypto";
|
|
92
|
+
import { createServer, createConnection } from "node:net";
|
|
93
|
+
import { createHue } from "@hue-run/sdk";
|
|
94
|
+
import { context, createContextKey, ROOT_CONTEXT, SpanKind } from "@opentelemetry/api";
|
|
95
|
+
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
|
|
96
|
+
|
|
97
|
+
// Generated application bootstrap owns this policy; the SDK core registers nothing.
|
|
98
|
+
const contextCheck = createContextKey("hue.setup.context-check");
|
|
99
|
+
async function hasWorkingContext() {
|
|
100
|
+
try {
|
|
101
|
+
return await context.with(ROOT_CONTEXT.setValue(contextCheck, true), async () => {
|
|
102
|
+
await Promise.resolve();
|
|
103
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
104
|
+
return context.active().getValue(contextCheck) === true;
|
|
105
|
+
});
|
|
106
|
+
} catch { return false; }
|
|
107
|
+
}
|
|
108
|
+
let ownedContext;
|
|
109
|
+
if (!(await hasWorkingContext())) {
|
|
110
|
+
const candidate = new AsyncLocalStorageContextManager().enable();
|
|
111
|
+
if (!context.setGlobalContextManager(candidate)) {
|
|
112
|
+
candidate.disable();
|
|
113
|
+
throw new Error("Existing OpenTelemetry context ownership is unsupported; review the application bootstrap");
|
|
114
|
+
}
|
|
115
|
+
ownedContext = candidate;
|
|
116
|
+
if (!(await hasWorkingContext())) {
|
|
117
|
+
ownedContext.disable();
|
|
118
|
+
throw new Error("This runtime does not support the setup context integration");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
process.once("beforeExit", () => { ownedContext?.disable(); ownedContext = undefined; });
|
|
122
|
+
|
|
123
|
+
const installation = JSON.parse(
|
|
124
|
+
readFileSync(fileURLToPath(new URL("./.hue/${basename(store.path)}", import.meta.url)), "utf8"),
|
|
125
|
+
);
|
|
126
|
+
if (!installation.credential?.apiKey) throw new Error("Run hue resume to recover Hue credentials");
|
|
127
|
+
|
|
128
|
+
export const hue = createHue({
|
|
129
|
+
apiKey: installation.credential.apiKey,
|
|
130
|
+
baseUrl: installation.origin,
|
|
131
|
+
serviceName: ${JSON.stringify(serviceName(store.projectRoot))},
|
|
132
|
+
captureContent: false,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const evidencePath = fileURLToPath(new URL("./.hue/${basename(store.applicationEvidencePath)}", import.meta.url));
|
|
136
|
+
const expressInstalled = Symbol.for("hue.setup.express.installed");
|
|
137
|
+
|
|
138
|
+
// Setup-only transport. Normal app serving never sends an ownership frame.
|
|
139
|
+
function installOwnedListener(app) {
|
|
140
|
+
const proof = process.env.HUE_SETUP_SOCKET_PROOF;
|
|
141
|
+
delete process.env.HUE_SETUP_SOCKET_PROOF;
|
|
142
|
+
if (proof === undefined) return;
|
|
143
|
+
if (!/^[A-Za-z0-9_-]{43}$/u.test(proof)) throw new Error("Invalid setup invocation");
|
|
144
|
+
const original = app.listen;
|
|
145
|
+
app.listen = function (port, host) {
|
|
146
|
+
if ((host !== undefined && host !== "127.0.0.1") || arguments.length > 2)
|
|
147
|
+
throw new Error("Unsupported setup listener ownership");
|
|
148
|
+
const inner = original.call(app, 0, "127.0.0.1");
|
|
149
|
+
let accepted = false;
|
|
150
|
+
const sockets = new Set();
|
|
151
|
+
const outer = createServer((socket) => {
|
|
152
|
+
if (accepted || !inner.listening) { socket.destroy(); return; }
|
|
153
|
+
accepted = true;
|
|
154
|
+
sockets.add(socket);
|
|
155
|
+
const upstream = createConnection({ host: "127.0.0.1", port: inner.address().port });
|
|
156
|
+
sockets.add(upstream);
|
|
157
|
+
const close = () => { socket.destroy(); upstream.destroy(); sockets.delete(socket); sockets.delete(upstream); };
|
|
158
|
+
socket.on("error", close); upstream.on("error", close);
|
|
159
|
+
socket.on("close", close); upstream.on("close", close);
|
|
160
|
+
socket.setTimeout(15000, close); upstream.setTimeout(15000, close);
|
|
161
|
+
upstream.once("connect", () => {
|
|
162
|
+
if (!inner.listening || socket.destroyed) { close(); return; }
|
|
163
|
+
const digest = createHmac("sha256", Buffer.from(proof, "base64url"))
|
|
164
|
+
.update("hue-setup-owned-v1\\0" + socket.localPort + "\\0" + socket.remotePort).digest("base64url");
|
|
165
|
+
socket.write("Hue-setup-owned:" + digest + "\\n");
|
|
166
|
+
socket.pipe(upstream); upstream.pipe(socket);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
const close = () => { for (const socket of sockets) socket.destroy(); inner.close(); };
|
|
170
|
+
outer.on("error", close); outer.on("close", close);
|
|
171
|
+
inner.on("error", () => outer.close());
|
|
172
|
+
inner.once("listening", () => outer.listen(Number(port), "127.0.0.1"));
|
|
173
|
+
return outer;
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function saveEvidence(value) {
|
|
178
|
+
if (process.env.HUE_SETUP_EVIDENCE_FILE !== evidencePath) return;
|
|
179
|
+
const temporary = evidencePath + "." + process.pid + ".tmp";
|
|
180
|
+
let descriptor;
|
|
181
|
+
try {
|
|
182
|
+
descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
183
|
+
writeFileSync(descriptor, JSON.stringify(value) + "\\n", "utf8");
|
|
184
|
+
fsyncSync(descriptor);
|
|
185
|
+
closeSync(descriptor);
|
|
186
|
+
descriptor = undefined;
|
|
187
|
+
renameSync(temporary, evidencePath);
|
|
188
|
+
} finally {
|
|
189
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
190
|
+
try { unlinkSync(temporary); } catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function installHueExpress(app, requestPath) {
|
|
195
|
+
if (app[expressInstalled]) throw new Error("Duplicate Hue setup middleware requires explicit review");
|
|
196
|
+
app[expressInstalled] = true;
|
|
197
|
+
installOwnedListener(app);
|
|
198
|
+
app.use((request, response, next) => {
|
|
199
|
+
void (async () => {
|
|
200
|
+
const ids = await hue.withSpan("hue.metadata", async (span) => {
|
|
201
|
+
const finished = new Promise((resolve) => {
|
|
202
|
+
response.once("finish", resolve);
|
|
203
|
+
response.once("close", resolve);
|
|
204
|
+
});
|
|
205
|
+
next();
|
|
206
|
+
await finished;
|
|
207
|
+
return { traceId: span.traceId, spanId: span.spanId };
|
|
208
|
+
}, { kind: SpanKind.SERVER });
|
|
209
|
+
await hue.flush();
|
|
210
|
+
if (request.method === "GET" && request.route?.path === requestPath && request.path === requestPath && response.statusCode >= 200 && response.statusCode < 300 && response.writableFinished)
|
|
211
|
+
saveEvidence({ ...ids, credentialVersion: installation.credential.version, source: "existing-application-request" });
|
|
212
|
+
})().catch(() => undefined);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
`;
|
|
216
|
+
}
|
|
217
|
+
function pythonConfig(store) {
|
|
218
|
+
return `# Managed by Hue setup. This file contains no credential.
|
|
219
|
+
import json
|
|
220
|
+
import os
|
|
221
|
+
import base64
|
|
222
|
+
import hashlib
|
|
223
|
+
import hmac
|
|
224
|
+
from pathlib import Path
|
|
225
|
+
|
|
226
|
+
from flask import g, request
|
|
227
|
+
from hue_sdk import Hue
|
|
228
|
+
from opentelemetry.trace import SpanKind, use_span
|
|
229
|
+
from werkzeug.serving import WSGIRequestHandler
|
|
230
|
+
|
|
231
|
+
_installation = json.loads(
|
|
232
|
+
(Path(__file__).parent / ".hue" / ${JSON.stringify(basename(store.path))}).read_text(encoding="utf-8")
|
|
233
|
+
)
|
|
234
|
+
if not _installation.get("credential", {}).get("apiKey"):
|
|
235
|
+
raise RuntimeError("Run hue resume to recover Hue credentials")
|
|
236
|
+
|
|
237
|
+
hue = Hue(
|
|
238
|
+
api_key=_installation["credential"]["apiKey"],
|
|
239
|
+
base_url=_installation["origin"],
|
|
240
|
+
service_name=${JSON.stringify(serviceName(store.projectRoot))},
|
|
241
|
+
capture_content=False,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
_evidence_path = Path(__file__).parent / ".hue" / ${JSON.stringify(basename(store.applicationEvidencePath))}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _save_evidence(value):
|
|
248
|
+
if os.environ.get("HUE_SETUP_EVIDENCE_FILE") != str(_evidence_path):
|
|
249
|
+
return
|
|
250
|
+
temporary = _evidence_path.with_name(f".{_evidence_path.name}.{os.getpid()}.tmp")
|
|
251
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
252
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
253
|
+
flags |= os.O_NOFOLLOW
|
|
254
|
+
descriptor = os.open(temporary, flags, 0o600)
|
|
255
|
+
try:
|
|
256
|
+
os.write(descriptor, (json.dumps(value) + "\\n").encode("utf-8"))
|
|
257
|
+
os.fsync(descriptor)
|
|
258
|
+
finally:
|
|
259
|
+
os.close(descriptor)
|
|
260
|
+
os.replace(temporary, _evidence_path)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def install_hue_flask(app, request_path):
|
|
264
|
+
if app.extensions.get("hue_setup_installed"):
|
|
265
|
+
raise RuntimeError("Duplicate Hue setup middleware requires explicit review")
|
|
266
|
+
app.extensions["hue_setup_installed"] = True
|
|
267
|
+
proof = os.environ.pop("HUE_SETUP_SOCKET_PROOF", None)
|
|
268
|
+
if proof is not None:
|
|
269
|
+
if len(proof) != 43 or any(char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" for char in proof):
|
|
270
|
+
raise RuntimeError("Invalid setup invocation")
|
|
271
|
+
accepted = False
|
|
272
|
+
class OwnedHandler(WSGIRequestHandler):
|
|
273
|
+
def handle(self):
|
|
274
|
+
nonlocal accepted
|
|
275
|
+
if accepted:
|
|
276
|
+
return
|
|
277
|
+
accepted = True
|
|
278
|
+
try:
|
|
279
|
+
self.connection.settimeout(15)
|
|
280
|
+
message = "hue-setup-owned-v1\\0" + str(self.connection.getsockname()[1]) + "\\0" + str(self.connection.getpeername()[1])
|
|
281
|
+
digest = base64.urlsafe_b64encode(hmac.new(base64.urlsafe_b64decode(proof + "="), message.encode("ascii"), hashlib.sha256).digest()).rstrip(b"=")
|
|
282
|
+
self.connection.sendall(b"Hue-setup-owned:" + digest + b"\\n")
|
|
283
|
+
super().handle()
|
|
284
|
+
except (OSError, ValueError):
|
|
285
|
+
pass
|
|
286
|
+
original_run = app.run
|
|
287
|
+
def owned_run(*args, **kwargs):
|
|
288
|
+
if args or set(kwargs) - {"host", "port"} or kwargs.get("host", "127.0.0.1") != "127.0.0.1":
|
|
289
|
+
raise RuntimeError("Unsupported setup listener ownership")
|
|
290
|
+
return original_run(**kwargs, request_handler=OwnedHandler, load_dotenv=False, use_reloader=False, threaded=False)
|
|
291
|
+
app.run = owned_run
|
|
292
|
+
|
|
293
|
+
@app.before_request
|
|
294
|
+
def _hue_setup_before_request():
|
|
295
|
+
try:
|
|
296
|
+
span = hue.tracer.start_span("hue.metadata", kind=SpanKind.SERVER)
|
|
297
|
+
context = use_span(span, end_on_exit=False, record_exception=False, set_status_on_exception=False)
|
|
298
|
+
context.__enter__()
|
|
299
|
+
g._hue_setup_context = context
|
|
300
|
+
g._hue_setup_span = span
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
@app.after_request
|
|
305
|
+
def _hue_setup_after_request(response):
|
|
306
|
+
try:
|
|
307
|
+
context = getattr(g, "_hue_setup_context", None)
|
|
308
|
+
span = getattr(g, "_hue_setup_span", None)
|
|
309
|
+
g._hue_setup_context = None
|
|
310
|
+
g._hue_setup_span = None
|
|
311
|
+
if context is not None:
|
|
312
|
+
context.__exit__(None, None, None)
|
|
313
|
+
if span is None:
|
|
314
|
+
return response
|
|
315
|
+
matched = request.method == "GET" and request.url_rule is not None and request.url_rule.rule == request_path and 200 <= response.status_code < 300
|
|
316
|
+
ended = False
|
|
317
|
+
def finish(complete):
|
|
318
|
+
nonlocal ended
|
|
319
|
+
if ended:
|
|
320
|
+
return
|
|
321
|
+
ended = True
|
|
322
|
+
try:
|
|
323
|
+
ids = span.get_span_context()
|
|
324
|
+
span.end()
|
|
325
|
+
if complete and matched and os.environ.get("HUE_SETUP_EVIDENCE_FILE") == str(_evidence_path) and hue.force_flush():
|
|
326
|
+
_save_evidence({"traceId": format(ids.trace_id, "032x"), "spanId": format(ids.span_id, "016x"), "credentialVersion": _installation["credential"]["version"], "source": "existing-application-request"})
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
if response.is_streamed:
|
|
330
|
+
original = response.response
|
|
331
|
+
def streamed():
|
|
332
|
+
complete = False
|
|
333
|
+
try:
|
|
334
|
+
with use_span(span, end_on_exit=False, record_exception=False, set_status_on_exception=False):
|
|
335
|
+
yield from original
|
|
336
|
+
complete = True
|
|
337
|
+
finally:
|
|
338
|
+
finish(complete)
|
|
339
|
+
response.response = streamed()
|
|
340
|
+
response.call_on_close(lambda: finish(False))
|
|
341
|
+
else:
|
|
342
|
+
finish(True)
|
|
343
|
+
except Exception:
|
|
344
|
+
pass
|
|
345
|
+
return response
|
|
346
|
+
`;
|
|
347
|
+
}
|
|
348
|
+
async function atomicManagedWrite(path, contents, previous) {
|
|
349
|
+
await safeDirectory(dirname(path));
|
|
350
|
+
const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
351
|
+
let handle;
|
|
352
|
+
try {
|
|
353
|
+
handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, previous ? previous.info.mode & 0o777 : 0o644);
|
|
354
|
+
await handle.chmod(previous ? previous.info.mode & 0o777 : 0o644);
|
|
355
|
+
await handle.writeFile(contents, "utf8");
|
|
356
|
+
await handle.sync();
|
|
357
|
+
await handle.close();
|
|
358
|
+
handle = undefined;
|
|
359
|
+
if (!sameSnapshot(previous, await readManaged(path)))
|
|
360
|
+
throw new Error("Refusing setup configuration changed before replacement");
|
|
361
|
+
if (previous)
|
|
362
|
+
await rename(temporary, path);
|
|
363
|
+
else {
|
|
364
|
+
// link is an atomic create-if-absent: never replace a file created after validation.
|
|
365
|
+
await link(temporary, path);
|
|
366
|
+
await unlink(temporary);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
await handle?.close();
|
|
371
|
+
await cleanupTemporary(temporary);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async function writeManaged(store, record, relativePath, contents) {
|
|
375
|
+
const path = join(store.projectRoot, relativePath);
|
|
376
|
+
if (!inside(store.projectRoot, path))
|
|
377
|
+
throw new Error("Unsafe setup configuration path");
|
|
378
|
+
const expected = setupManagedDigest(contents);
|
|
379
|
+
const previous = await readManaged(path);
|
|
380
|
+
if (previous !== undefined && setupManagedDigest(previous.contents) === expected) {
|
|
381
|
+
if (record.managedFiles[relativePath] !== expected) {
|
|
382
|
+
record.managedFiles[relativePath] = expected;
|
|
383
|
+
await store.save(record);
|
|
384
|
+
}
|
|
385
|
+
return undefined;
|
|
386
|
+
}
|
|
387
|
+
const savedDigest = record.managedFiles[relativePath];
|
|
388
|
+
if (previous && (!savedDigest || setupManagedDigest(previous.contents) !== savedDigest))
|
|
389
|
+
throw new Error(`Refusing to overwrite custom or unexpectedly edited configuration at ${relativePath}`);
|
|
390
|
+
await atomicManagedWrite(path, contents, previous);
|
|
391
|
+
record.managedFiles[relativePath] = expected;
|
|
392
|
+
await store.save(record);
|
|
393
|
+
return { path: relativePath, change: previous ? "updated" : "created" };
|
|
394
|
+
}
|
|
395
|
+
async function rejectCustomEnvironment(projectRoot) {
|
|
396
|
+
await safeDirectory(projectRoot);
|
|
397
|
+
if (process.env.HUE_API_KEY)
|
|
398
|
+
throw new Error("Refusing to replace an existing custom Hue credential from HUE_API_KEY");
|
|
399
|
+
const entries = await readdir(projectRoot, { withFileTypes: true });
|
|
400
|
+
if (entries.length > 10_000)
|
|
401
|
+
throw new Error("Refusing to scan an oversized project directory");
|
|
402
|
+
for (const entry of entries) {
|
|
403
|
+
if (!(entry.name === ".env" || entry.name.startsWith(".env.")))
|
|
404
|
+
continue;
|
|
405
|
+
if (entry.isSymbolicLink())
|
|
406
|
+
throw new Error(`Refusing unsafe credential file at ${entry.name}`);
|
|
407
|
+
if (!entry.isFile())
|
|
408
|
+
continue;
|
|
409
|
+
const path = join(projectRoot, entry.name);
|
|
410
|
+
const source = (await readManaged(path))?.contents;
|
|
411
|
+
if (source === undefined)
|
|
412
|
+
throw new Error("Refusing credential file changed during inspection");
|
|
413
|
+
if (/^\s*(?:export\s+)?HUE_API_KEY\s*=/mu.test(source))
|
|
414
|
+
throw new Error(`Refusing to replace an existing custom Hue credential in ${entry.name}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/** Refuses credential/config conflicts before setup makes a provisioning request. */
|
|
418
|
+
export async function validateSetupConfiguration(store, record, project) {
|
|
419
|
+
if (project.root !== store.projectRoot)
|
|
420
|
+
throw new Error("Setup project identity changed");
|
|
421
|
+
if (project.languages.length === 0)
|
|
422
|
+
throw new Error("No supported TypeScript or Python project was detected");
|
|
423
|
+
await rejectCustomEnvironment(store.projectRoot);
|
|
424
|
+
const candidates = [];
|
|
425
|
+
if (project.languages.includes("typescript"))
|
|
426
|
+
candidates.push(["hue.setup.mjs", typescriptConfig(store)]);
|
|
427
|
+
if (project.languages.includes("python"))
|
|
428
|
+
candidates.push(["hue_setup.py", pythonConfig(store)]);
|
|
429
|
+
for (const [relativePath, expected] of candidates) {
|
|
430
|
+
const path = join(store.projectRoot, relativePath);
|
|
431
|
+
const previous = await readManaged(path);
|
|
432
|
+
if (previous) {
|
|
433
|
+
const digest = setupManagedDigest(previous.contents);
|
|
434
|
+
if (digest !== setupManagedDigest(expected) && digest !== record?.managedFiles[relativePath])
|
|
435
|
+
throw new Error(`Refusing to overwrite custom or unexpectedly edited configuration at ${relativePath}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/** Writes only secret-free, metadata-only integration modules and never executes project code. */
|
|
440
|
+
export async function configureSetupProject(store, record, project) {
|
|
441
|
+
await validateSetupConfiguration(store, record, project);
|
|
442
|
+
const changes = [];
|
|
443
|
+
if (project.languages.includes("typescript")) {
|
|
444
|
+
const change = await writeManaged(store, record, "hue.setup.mjs", typescriptConfig(store));
|
|
445
|
+
if (change)
|
|
446
|
+
changes.push(change);
|
|
447
|
+
}
|
|
448
|
+
if (project.languages.includes("python")) {
|
|
449
|
+
const change = await writeManaged(store, record, "hue_setup.py", pythonConfig(store));
|
|
450
|
+
if (change)
|
|
451
|
+
changes.push(change);
|
|
452
|
+
}
|
|
453
|
+
return changes;
|
|
454
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Closed pre-release setup credential grammar, distinct from ordinary project service keys. */
|
|
2
|
+
const SETUP_KEY = /^hue_setup_(live|test)_setup-([a-f0-9]{24})_([A-Za-z0-9_-]{43})$/u;
|
|
3
|
+
/** Validates both the bearer namespace and its exact stored key identity. */
|
|
4
|
+
export function validSetupCredentialIdentity(apiKey, keyId) {
|
|
5
|
+
if (typeof apiKey !== "string" || typeof keyId !== "string")
|
|
6
|
+
return false;
|
|
7
|
+
const match = SETUP_KEY.exec(apiKey);
|
|
8
|
+
return match !== null && keyId === `setup-${match[2]}`;
|
|
9
|
+
}
|
package/dist/setup/detect.js
CHANGED
|
@@ -86,7 +86,10 @@ export async function detectSetupProject(projectRoot) {
|
|
|
86
86
|
.filter((value) => value !== undefined)
|
|
87
87
|
.join("\n");
|
|
88
88
|
const languages = [];
|
|
89
|
-
|
|
89
|
+
// The existing public language discriminator covers JavaScript as well as TypeScript.
|
|
90
|
+
if (contents.has("tsconfig.json") ||
|
|
91
|
+
nodeDependencies.has("typescript") ||
|
|
92
|
+
nodeDependencies.has("express"))
|
|
90
93
|
languages.push("typescript");
|
|
91
94
|
if (python || contents.has("pyproject.toml"))
|
|
92
95
|
languages.push("python");
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Telemetry credential saved only in an ignored owner-only installation file. */
|
|
2
|
+
export interface SetupStoredCredential {
|
|
3
|
+
/** Fixed setup-only key family; never an ordinary account-managed key. */
|
|
4
|
+
kind: "anonymous_trial";
|
|
5
|
+
/** Setup credentials have exactly one metadata-only permission in both generations. */
|
|
6
|
+
capabilities: ["setup_telemetry_write"];
|
|
7
|
+
/** Bearer credential used solely for telemetry export and receipt verification. */
|
|
8
|
+
apiKey: string;
|
|
9
|
+
/** Server identifier for the credential generation. */
|
|
10
|
+
keyId: string;
|
|
11
|
+
/** Anonymous or post-claim credential generation. */
|
|
12
|
+
version: 0 | 1;
|
|
13
|
+
}
|
|
14
|
+
/** Exact metadata probe identifiers retained for resumable receipt verification. */
|
|
15
|
+
export interface SetupStoredProbe {
|
|
16
|
+
/** Lowercase external trace identifier. */
|
|
17
|
+
traceId: string;
|
|
18
|
+
/** Lowercase external span identifier expected in the receipt. */
|
|
19
|
+
spanId: string;
|
|
20
|
+
/** Credential generation that exported the probe. */
|
|
21
|
+
credentialVersion: 0 | 1;
|
|
22
|
+
/** Whether an exact metadata-only receipt was observed. */
|
|
23
|
+
verified: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** Exact identifiers emitted by a request through the repository's existing application. */
|
|
26
|
+
export interface SetupStoredApplicationEvidence extends SetupStoredProbe {
|
|
27
|
+
/** Closed source label that distinguishes app evidence from a synthetic setup probe. */
|
|
28
|
+
source: "existing-application-request";
|
|
29
|
+
}
|
|
30
|
+
/** Durable no-replay marker written before starting an existing application request. */
|
|
31
|
+
export interface SetupStoredApplicationAttempt {
|
|
32
|
+
/** Credential generation for which application work was attempted once. */
|
|
33
|
+
credentialVersion: 0 | 1;
|
|
34
|
+
/** Bounded ISO timestamp for diagnostics and explicit recovery. */
|
|
35
|
+
startedAt: string;
|
|
36
|
+
}
|
|
37
|
+
/** Durable client identity for an idempotent one-time browser handoff request. */
|
|
38
|
+
export interface SetupStoredClaimHandoff {
|
|
39
|
+
/** Lowercase UUIDv4 persisted before the handoff request is sent. */
|
|
40
|
+
id: string;
|
|
41
|
+
/** Compare-and-swap predecessor supplied when this handoff was created. */
|
|
42
|
+
previousHandoffId: string | null;
|
|
43
|
+
/** Last strictly validated server state, when a response was received. */
|
|
44
|
+
state?: "pending" | "consumed" | "expired" | "revoked";
|
|
45
|
+
/** Fixed server expiry, when a response was received. */
|
|
46
|
+
expiresAt?: string;
|
|
47
|
+
/** Fixed browser session expiry, when an exchange succeeded. */
|
|
48
|
+
sessionExpiresAt?: string | null;
|
|
49
|
+
}
|
|
50
|
+
/** Secret local installation state. It must never be emitted or copied into diagnostics. */
|
|
51
|
+
export interface SetupInstallationRecord {
|
|
52
|
+
/** Local file format version. */
|
|
53
|
+
format: 1;
|
|
54
|
+
/** Exact normalized Hue origin owning this installation. */
|
|
55
|
+
origin: string;
|
|
56
|
+
/** Lowercase UUIDv4 installation identity. */
|
|
57
|
+
installationId: string;
|
|
58
|
+
/** Unpadded base64url installation proof; never log or diagnose this value. */
|
|
59
|
+
installationSecret: string;
|
|
60
|
+
/** Latest locally managed telemetry credential. */
|
|
61
|
+
credential?: SetupStoredCredential;
|
|
62
|
+
/** Superseded anonymous credential retained only until its revocation is verified. */
|
|
63
|
+
revocationCredential?: SetupStoredCredential;
|
|
64
|
+
/** Durable positive verification of the superseded key on the dedicated receipt route. */
|
|
65
|
+
anonymousKeyRevoked?: true;
|
|
66
|
+
/** Latest probe awaiting or carrying exact receipt evidence. */
|
|
67
|
+
probe?: SetupStoredProbe;
|
|
68
|
+
/** Latest existing-application request awaiting or carrying exact receipt evidence. */
|
|
69
|
+
applicationEvidence?: SetupStoredApplicationEvidence;
|
|
70
|
+
/** Prevents automatic replay after startup, request, export or evidence loss. */
|
|
71
|
+
applicationAttempt?: SetupStoredApplicationAttempt;
|
|
72
|
+
/** Current proof-bound one-time browser handoff identity; never a bearer capability. */
|
|
73
|
+
claimHandoff?: SetupStoredClaimHandoff;
|
|
74
|
+
/** Provision request timestamps used to enforce the local hourly bound. */
|
|
75
|
+
provisionAttempts: string[];
|
|
76
|
+
/** Digests of files setup owns and may safely replace. */
|
|
77
|
+
managedFiles: Record<string, string>;
|
|
78
|
+
}
|
|
79
|
+
/** Owner-only, project/origin-scoped storage for installation proof and telemetry credentials. */
|
|
80
|
+
export declare class FileSetupInstallationStore {
|
|
81
|
+
private lastSnapshot?;
|
|
82
|
+
/** Resolved project directory containing the installation state. */
|
|
83
|
+
readonly projectRoot: string;
|
|
84
|
+
/** Exact normalized Hue origin scoped to this store. */
|
|
85
|
+
readonly origin: string;
|
|
86
|
+
/** Owner-only `.hue` directory. */
|
|
87
|
+
readonly directory: string;
|
|
88
|
+
/** Origin-scoped ignored installation record path. */
|
|
89
|
+
readonly path: string;
|
|
90
|
+
/** Origin-scoped owner-only browser handoff; its contents are never emitted. */
|
|
91
|
+
readonly claimHandoffPath: string;
|
|
92
|
+
/** Origin-scoped private application evidence transfer path. */
|
|
93
|
+
readonly applicationEvidencePath: string;
|
|
94
|
+
constructor(projectRoot: string, origin: string);
|
|
95
|
+
private rejectUnsafeProjectRoot;
|
|
96
|
+
private hasGitWorktree;
|
|
97
|
+
private privatePaths;
|
|
98
|
+
private assertPrivateGitProtection;
|
|
99
|
+
private snapshot;
|
|
100
|
+
private ensureIgnoreFile;
|
|
101
|
+
private ensureRootIgnored;
|
|
102
|
+
/** Revalidates owner-only storage and ignore rules before an existing proof is used for I/O. */
|
|
103
|
+
ensureIgnored(): Promise<void>;
|
|
104
|
+
/** Loads a valid owner-only record without creating one. */
|
|
105
|
+
load(): Promise<SetupInstallationRecord | undefined>;
|
|
106
|
+
/** Creates and durably saves the installation proof before any caller may perform network I/O. */
|
|
107
|
+
loadOrCreate(): Promise<SetupInstallationRecord>;
|
|
108
|
+
/** Atomically replaces this store's validated owner-only record. */
|
|
109
|
+
save(record: SetupInstallationRecord): Promise<void>;
|
|
110
|
+
/** Saves a private browser redirect without putting its capability in a process argument. */
|
|
111
|
+
saveClaimHandoff(claimUrl: string): Promise<string>;
|
|
112
|
+
/** Removes a consumed or terminal claim handoff without following symlinks. */
|
|
113
|
+
removeClaimHandoff(): Promise<void>;
|
|
114
|
+
/** Removes the private child-to-parent evidence transfer file without following symlinks. */
|
|
115
|
+
removeApplicationEvidence(): Promise<void>;
|
|
116
|
+
}
|
|
117
|
+
/** Computes the digest used to detect unexpected edits to managed files. */
|
|
118
|
+
export declare function setupManagedDigest(contents: string): string;
|