@hypit/hypit 0.1.12 → 0.1.13
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/dist/public/model-kit.d.ts +16 -0
- package/package.json +2 -1
- package/packages/cli/README.md +3 -0
- package/packages/cli/src/commands/results.ts +19 -3
- package/packages/cli/src/output.ts +2 -2
- package/packages/cli/src/source-discovery.ts +2 -2
- package/packages/model-kit/README.md +28 -0
- package/packages/model-kit/src/index.ts +58 -19
- package/packages/project-context-node/README.md +2 -0
- package/packages/project-context-node/src/project-context.ts +5 -3
- package/packages/provider-hyperframes-local/README.md +14 -5
- package/packages/provider-hyperframes-local/src/capture-process.ts +26 -8
- package/packages/provider-hyperframes-local/src/capture-worker.ts +26 -7
- package/packages/provider-hypihub/README.md +3 -0
- package/packages/runtime-local/src/runtime.ts +1 -1
- package/packages/seedance/README.md +42 -0
- package/packages/seedance/src/index.ts +5 -22
- package/packages/seedance/src/surface.ts +2 -3
- package/packages/seedance/src/validation.ts +16 -0
- package/packages/studio/src/server.ts +2 -2
- package/packages/workspace-fs-node/src/workspace.ts +2 -2
|
@@ -380,6 +380,15 @@ type GenerationMediaValue = {
|
|
|
380
380
|
};
|
|
381
381
|
type GenerationPortValue = string | number | boolean | GenerationMediaValue;
|
|
382
382
|
|
|
383
|
+
/**
|
|
384
|
+
* The one request envelope every exact model uses. A Provider reads ports by
|
|
385
|
+
* name and never learns a model-specific field layout, so the same wire mapping
|
|
386
|
+
* mechanism serves every model and every service reselling it.
|
|
387
|
+
*/
|
|
388
|
+
type GenerationRequest = {
|
|
389
|
+
/** An absent port is an omitted key. A present port always carries at least one value. */
|
|
390
|
+
readonly ports: Readonly<Record<string, readonly GenerationPortValue[]>>;
|
|
391
|
+
};
|
|
383
392
|
/**
|
|
384
393
|
* A request while graph inputs are still being attached. It is deliberately a
|
|
385
394
|
* Providers can only receive the finalized request after the exact model
|
|
@@ -423,6 +432,10 @@ type ExactModelEndpointSpec = {
|
|
|
423
432
|
readonly requestTypeName: string;
|
|
424
433
|
readonly producerName: string;
|
|
425
434
|
readonly ports: GenerationPortTable;
|
|
435
|
+
/** Additional complete-request rules, after port and supplied-input validation. */
|
|
436
|
+
readonly validateRequest?: (request: GenerationRequest) => void;
|
|
437
|
+
/** Pure, synchronous checks of supplied values, in drafts and complete requests; throw to reject. */
|
|
438
|
+
readonly validateInputs?: (inputs: GenerationRequestDraft) => void;
|
|
426
439
|
};
|
|
427
440
|
type ExactModelEndpoint = {
|
|
428
441
|
readonly key: string;
|
|
@@ -435,6 +448,9 @@ type ExactModelEndpoint = {
|
|
|
435
448
|
readonly mediaBindings: Readonly<Record<string, ExactModelMediaBindingEndpoint>>;
|
|
436
449
|
readonly textBindings: Readonly<Record<string, ExactModelTextBindingEndpoint>>;
|
|
437
450
|
readonly ports: GenerationPortTable;
|
|
451
|
+
readonly validateRequest: (request: unknown) => void;
|
|
452
|
+
readonly validateDraft: (draft: unknown) => void;
|
|
453
|
+
readonly sealRequest: (ports: GenerationRequest["ports"]) => GenerationRequest;
|
|
438
454
|
readonly fragment: ReturnType<typeof sealGraphFragment>;
|
|
439
455
|
};
|
|
440
456
|
type ExactModelMediaBindingEndpoint = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypit/hypit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"homepage": "https://hypit.ai",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -168,6 +168,7 @@
|
|
|
168
168
|
],
|
|
169
169
|
"scripts": {
|
|
170
170
|
"pack:distribution": "node scripts/pack-distribution.mjs",
|
|
171
|
+
"check:distribution": "node scripts/check-distribution.mjs",
|
|
171
172
|
"build:public-types": "node scripts/build-public-types.mjs",
|
|
172
173
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
173
174
|
"docs:dev": "vitepress dev docs",
|
package/packages/cli/README.md
CHANGED
|
@@ -148,6 +148,9 @@ from a lookup that still needs the Build's Runtime or correct project selection.
|
|
|
148
148
|
Worker process log instead, for Runtime startup or process-level failures.
|
|
149
149
|
|
|
150
150
|
Project context resolution is owned by [`@hypit/project-context-node`](../project-context-node/README.md).
|
|
151
|
+
History Source filters resolve existing filesystem links before comparing project-relative Result
|
|
152
|
+
paths. A deleted Source or directory remains queryable: only its existing ancestor is resolved and
|
|
153
|
+
the missing path suffix is retained. This is local argument handling, with no saved alias inventory.
|
|
151
154
|
CLI, Studio and creation tools call that same package; the CLI is not another environment owner.
|
|
152
155
|
|
|
153
156
|
`doctor`, `programs up|status|down`, and `runtime up` accept repeated `--endpoint <instance>` values.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, resolve } from "node:path";
|
|
2
3
|
|
|
3
4
|
import type { BuildResultRepository } from "@hypit/build-result";
|
|
4
5
|
|
|
@@ -20,6 +21,21 @@ export function isProjectResultCommand(args: CliCommand): args is ProjectResultC
|
|
|
20
21
|
|| args.command === "get" || (args.command === "result" && args.action === "edit");
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/** History can name a deleted Source. Resolve existing directory links, keeping the absent suffix. */
|
|
25
|
+
async function historySourcePath(path: string): Promise<string> {
|
|
26
|
+
let existing = resolve(path);
|
|
27
|
+
const suffix: string[] = [];
|
|
28
|
+
while (true) {
|
|
29
|
+
try { return resolve(await realpath(existing), ...suffix); }
|
|
30
|
+
catch (error) {
|
|
31
|
+
const parent = dirname(existing);
|
|
32
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || parent === existing) throw error;
|
|
33
|
+
suffix.unshift(basename(existing));
|
|
34
|
+
existing = parent;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
/** Execute commands that need only project-owned Result history, never a Runtime. */
|
|
24
40
|
export async function runProjectResultCommand(input: {
|
|
25
41
|
readonly args: ProjectResultCommand;
|
|
@@ -62,7 +78,7 @@ export async function runProjectResultCommand(input: {
|
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
if (args.command === "history") {
|
|
65
|
-
const source = args.source === undefined ? undefined :
|
|
81
|
+
const source = args.source === undefined ? undefined : await historySourcePath(args.source);
|
|
66
82
|
const page = await browseBuildOutputHistory(repository, {
|
|
67
83
|
projectRoot,
|
|
68
84
|
output: args.outputName,
|
|
@@ -177,7 +193,7 @@ export async function runProjectResultCommand(input: {
|
|
|
177
193
|
kind: exported.kind,
|
|
178
194
|
path: exported.path,
|
|
179
195
|
};
|
|
180
|
-
const path = projectPath(exported.path, projectRoot);
|
|
196
|
+
const path = projectPath(await realpath(exported.path), projectRoot);
|
|
181
197
|
write(machine, `Exported ${exported.output} → ${path}`, "success",
|
|
182
198
|
args.presentation.verbose ? [
|
|
183
199
|
["Build", exported.build],
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { relative, resolve } from "node:path";
|
|
1
|
+
import { relative, resolve, sep } from "node:path";
|
|
2
2
|
|
|
3
3
|
import type { CanonicalValue } from "@hypit/protocol";
|
|
4
4
|
|
|
@@ -279,7 +279,7 @@ function glyph(io: CliIo, unicode: string, ascii: string): string {
|
|
|
279
279
|
function shortPath(path: string): string {
|
|
280
280
|
const absolute = resolve(path);
|
|
281
281
|
const local = relative(process.cwd(), absolute);
|
|
282
|
-
return local.length > 0 && !local.startsWith(
|
|
282
|
+
return local.length > 0 && local !== ".." && !local.startsWith(`..${sep}`) ? local : absolute;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
285
|
function facts(rows: readonly (readonly [string, string])[], colors: Palette): string[] {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile, realpath } from "node:fs/promises";
|
|
2
|
-
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { authorFrontendsFromHostFacets, prepareAuthorSource } from "@hypit/elaborator";
|
|
5
5
|
import type { AuthorFrontend } from "@hypit/elaborator";
|
|
@@ -12,7 +12,7 @@ import { parseSourceHeader, sourceFrontendPackageAbi } from "@hypit/source";
|
|
|
12
12
|
|
|
13
13
|
function isWithin(root: string, path: string): boolean {
|
|
14
14
|
const relation = relative(root, path);
|
|
15
|
-
return relation === "" || (!relation.startsWith(
|
|
15
|
+
return relation === "" || (relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function selectedPackage(request: string): string {
|
|
@@ -50,3 +50,31 @@ exact fields, constraints, model identity, capability name and validator. The he
|
|
|
50
50
|
Provider routing, fallback, credentials or Runtime authority.
|
|
51
51
|
|
|
52
52
|
This is a package-authoring utility, not an author-importable model by itself.
|
|
53
|
+
|
|
54
|
+
## Model-owned request checks
|
|
55
|
+
|
|
56
|
+
An endpoint may supply two synchronous, pure functions in addition to its port table:
|
|
57
|
+
|
|
58
|
+
- `validateInputs(inputs)` checks values already supplied, both in drafts and complete requests.
|
|
59
|
+
Text and media may still be missing from a draft. Use this for rules such as a reference's media
|
|
60
|
+
type; do not require a future input to exist.
|
|
61
|
+
- `validateRequest(request)` checks additional relationships that require the complete request.
|
|
62
|
+
It runs after the port table and `validateInputs`, when every required input is available.
|
|
63
|
+
|
|
64
|
+
Both return `void` and throw a useful error to reject. They read the supplied values only: no IO,
|
|
65
|
+
Provider selection, request mutation or saved validation result. Existing port rules remain in the
|
|
66
|
+
port table. The helper supplies structural validation before calling either function.
|
|
67
|
+
|
|
68
|
+
Register each rule once in the endpoint definition. The helper connects these functions to Type
|
|
69
|
+
validators, media/Text bindings, finalization, generation and planning. Planning checks the known
|
|
70
|
+
values and leaves future media on the existing graph edges. A directly supplied request or an
|
|
71
|
+
existing Need receives full validation, just as it does during execution.
|
|
72
|
+
|
|
73
|
+
For a public request builder, use `model.endpoints.image.sealRequest(ports)`: it canonicalizes the
|
|
74
|
+
request and applies all model rules. `endpoint.validateRequest(value)` checks an existing complete
|
|
75
|
+
request; `endpoint.validateDraft(value)` checks a partially assembled one. Calling the common
|
|
76
|
+
generation port helpers alone applies only the port table, not these model-owned functions.
|
|
77
|
+
|
|
78
|
+
Keep service-specific limits in the Provider. Its existing `supports` and pre-submission preparation
|
|
79
|
+
should share its own checks, rejecting known unsupported values before resource transfer. This does
|
|
80
|
+
not require the Provider to import the Model implementation or add any fields to the request.
|
|
@@ -13,13 +13,13 @@ import type { HostFacet } from "@hypit/host";
|
|
|
13
13
|
import {
|
|
14
14
|
bindGenerationMedia,
|
|
15
15
|
bindGenerationText,
|
|
16
|
-
finalizeGenerationRequestDraft,
|
|
17
16
|
generationModuleRef,
|
|
18
17
|
generationProducers,
|
|
19
18
|
generationTypes,
|
|
20
19
|
mediaBindingSchemaFromPort,
|
|
21
20
|
requestDraftSchemaFromPorts,
|
|
22
21
|
requestSchemaFromPorts,
|
|
22
|
+
sealGenerationPortRequest,
|
|
23
23
|
verifyGenerationMediaBinding,
|
|
24
24
|
verifyRequestDraftAgainstPorts,
|
|
25
25
|
verifyRequestAgainstPorts,
|
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
GenerationMediaBinding,
|
|
29
29
|
GenerationMediaPort,
|
|
30
30
|
GenerationPortTable,
|
|
31
|
+
GenerationRequest,
|
|
31
32
|
GenerationRequestDraft,
|
|
32
33
|
} from "@hypit/generation";
|
|
33
34
|
import { textDependency, textTypes } from "@hypit/text";
|
|
@@ -76,6 +77,10 @@ export type ExactModelEndpointSpec = {
|
|
|
76
77
|
readonly requestTypeName: string;
|
|
77
78
|
readonly producerName: string;
|
|
78
79
|
readonly ports: GenerationPortTable;
|
|
80
|
+
/** Additional complete-request rules, after port and supplied-input validation. */
|
|
81
|
+
readonly validateRequest?: (request: GenerationRequest) => void;
|
|
82
|
+
/** Pure, synchronous checks of supplied values, in drafts and complete requests; throw to reject. */
|
|
83
|
+
readonly validateInputs?: (inputs: GenerationRequestDraft) => void;
|
|
79
84
|
};
|
|
80
85
|
|
|
81
86
|
export type ExactModelEndpoint = {
|
|
@@ -89,6 +94,9 @@ export type ExactModelEndpoint = {
|
|
|
89
94
|
readonly mediaBindings: Readonly<Record<string, ExactModelMediaBindingEndpoint>>;
|
|
90
95
|
readonly textBindings: Readonly<Record<string, ExactModelTextBindingEndpoint>>;
|
|
91
96
|
readonly ports: GenerationPortTable;
|
|
97
|
+
readonly validateRequest: (request: unknown) => void;
|
|
98
|
+
readonly validateDraft: (draft: unknown) => void;
|
|
99
|
+
readonly sealRequest: (ports: GenerationRequest["ports"]) => GenerationRequest;
|
|
92
100
|
readonly fragment: ReturnType<typeof sealGraphFragment>;
|
|
93
101
|
};
|
|
94
102
|
|
|
@@ -267,7 +275,7 @@ export function plannedExactModelRequest(
|
|
|
267
275
|
|
|
268
276
|
const knownNeed = state.needs.find((need) => need.id === binding.id);
|
|
269
277
|
if (knownNeed !== undefined) {
|
|
270
|
-
|
|
278
|
+
endpoint.validateRequest(knownNeed.constraints);
|
|
271
279
|
return {
|
|
272
280
|
model: endpoint.ports.model,
|
|
273
281
|
ports: structuredClone((knownNeed.constraints as unknown as GenerationRequestDraft).ports),
|
|
@@ -281,6 +289,12 @@ export function plannedExactModelRequest(
|
|
|
281
289
|
Object.values(step.outputs).map((record) => [record, step] as const)));
|
|
282
290
|
const requestRecord = generationStep.inputs.request;
|
|
283
291
|
if (requestRecord === undefined) return undefined;
|
|
292
|
+
const knownRequest = records.get(requestRecord);
|
|
293
|
+
if (knownRequest !== undefined) {
|
|
294
|
+
const request = inlineValue<GenerationRequest>(knownRequest.value, `${endpoint.ports.model} request`);
|
|
295
|
+
endpoint.validateRequest(request);
|
|
296
|
+
return { model: endpoint.ports.model, ports: structuredClone(request.ports), pendingMedia: [], complete: false };
|
|
297
|
+
}
|
|
284
298
|
const finalize = producedBy.get(requestRecord);
|
|
285
299
|
if (finalize === undefined || !sameReference(finalize.producer, endpoint.finalizeProducer)) return undefined;
|
|
286
300
|
const finalDraft = finalize.inputs.draft;
|
|
@@ -295,7 +309,7 @@ export function plannedExactModelRequest(
|
|
|
295
309
|
const record = records.get(recordId);
|
|
296
310
|
if (record !== undefined) {
|
|
297
311
|
const draft = inlineValue<GenerationRequestDraft>(record.value, `${endpoint.ports.model} request draft`);
|
|
298
|
-
|
|
312
|
+
endpoint.validateDraft(draft);
|
|
299
313
|
return draft;
|
|
300
314
|
}
|
|
301
315
|
const step = producedBy.get(recordId);
|
|
@@ -354,6 +368,8 @@ export function plannedExactModelRequest(
|
|
|
354
368
|
};
|
|
355
369
|
|
|
356
370
|
const draft = rebuildDraft(finalDraft);
|
|
371
|
+
endpoint.validateDraft(draft);
|
|
372
|
+
if (pendingMedia.length === 0) endpoint.validateRequest(draft);
|
|
357
373
|
return {
|
|
358
374
|
model: endpoint.ports.model,
|
|
359
375
|
ports: structuredClone(draft.ports),
|
|
@@ -502,6 +518,15 @@ export function defineExactModelModule<const Key extends string>(
|
|
|
502
518
|
};
|
|
503
519
|
|
|
504
520
|
const endpoints = Object.fromEntries(endpointData.map((item): [Key, ExactModelEndpoint] => {
|
|
521
|
+
const validateRequest = (request: unknown): void => {
|
|
522
|
+
verifyRequestAgainstPorts(item.spec.ports, request);
|
|
523
|
+
item.spec.validateInputs?.(request);
|
|
524
|
+
item.spec.validateRequest?.(request);
|
|
525
|
+
};
|
|
526
|
+
const validateDraft = (draft: unknown): void => {
|
|
527
|
+
verifyRequestDraftAgainstPorts(item.spec.ports, draft);
|
|
528
|
+
item.spec.validateInputs?.(draft);
|
|
529
|
+
};
|
|
505
530
|
const fragment = sealGraphFragment({
|
|
506
531
|
inputs: [{ name: "request", type: item.requestType }],
|
|
507
532
|
operations: [{
|
|
@@ -527,6 +552,14 @@ export function defineExactModelModule<const Key extends string>(
|
|
|
527
552
|
mediaBindings: item.mediaBindings,
|
|
528
553
|
textBindings: item.textBindings,
|
|
529
554
|
ports: item.spec.ports,
|
|
555
|
+
validateRequest,
|
|
556
|
+
validateDraft,
|
|
557
|
+
sealRequest(ports) {
|
|
558
|
+
const request = sealGenerationPortRequest(item.spec.ports, ports);
|
|
559
|
+
item.spec.validateInputs?.(request);
|
|
560
|
+
item.spec.validateRequest?.(request);
|
|
561
|
+
return request;
|
|
562
|
+
},
|
|
530
563
|
fragment,
|
|
531
564
|
} satisfies ExactModelEndpoint];
|
|
532
565
|
}));
|
|
@@ -540,12 +573,12 @@ export function defineExactModelModule<const Key extends string>(
|
|
|
540
573
|
validators: endpointData.flatMap((item) => [{
|
|
541
574
|
type: item.requestType,
|
|
542
575
|
handler({ value }) {
|
|
543
|
-
|
|
576
|
+
endpoints[item.spec.key]!.validateRequest(inlineRequest(value, item.spec.key));
|
|
544
577
|
},
|
|
545
578
|
}, {
|
|
546
579
|
type: item.draftType,
|
|
547
580
|
handler({ value }) {
|
|
548
|
-
|
|
581
|
+
endpoints[item.spec.key]!.validateDraft(inlineRequest(value, `${item.spec.key} draft`));
|
|
549
582
|
},
|
|
550
583
|
}]),
|
|
551
584
|
producers: endpointData.flatMap((item) => [
|
|
@@ -555,7 +588,7 @@ export function defineExactModelModule<const Key extends string>(
|
|
|
555
588
|
const requestRecord = inputs.request;
|
|
556
589
|
assert(requestRecord !== undefined, `${item.spec.key} request input is missing`);
|
|
557
590
|
const request = inlineRequest(requestRecord.value, item.spec.key);
|
|
558
|
-
|
|
591
|
+
endpoints[item.spec.key]!.validateRequest(request);
|
|
559
592
|
return { outputs: {}, needs: { generation: request } };
|
|
560
593
|
},
|
|
561
594
|
},
|
|
@@ -570,11 +603,13 @@ export function defineExactModelModule<const Key extends string>(
|
|
|
570
603
|
verifyGenerationMediaBinding(port, value);
|
|
571
604
|
const artifact = inputs.artifact!.value;
|
|
572
605
|
assert(artifact.kind === "blob", `${binding.port} artifact must be a Blob`);
|
|
606
|
+
const bound = bindGenerationMedia(item.spec.ports, draft, binding.port, value, artifact);
|
|
607
|
+
endpoints[item.spec.key]!.validateDraft(bound);
|
|
573
608
|
return {
|
|
574
609
|
outputs: {
|
|
575
610
|
draft: {
|
|
576
611
|
kind: "inline" as const,
|
|
577
|
-
value: canonicalize(
|
|
612
|
+
value: canonicalize(bound),
|
|
578
613
|
},
|
|
579
614
|
},
|
|
580
615
|
needs: {},
|
|
@@ -586,11 +621,13 @@ export function defineExactModelModule<const Key extends string>(
|
|
|
586
621
|
handler: ({ inputs }: ProducerHandlerContext) => {
|
|
587
622
|
const draft = inlineValue<GenerationRequestDraft>(inputs.draft!.value, `${item.spec.key} draft`);
|
|
588
623
|
const text = inlineValue<Text>(inputs.text!.value, `${binding.port} Text`);
|
|
624
|
+
const bound = bindGenerationText(item.spec.ports, draft, binding.port, text);
|
|
625
|
+
endpoints[item.spec.key]!.validateDraft(bound);
|
|
589
626
|
return {
|
|
590
627
|
outputs: {
|
|
591
628
|
draft: {
|
|
592
629
|
kind: "inline" as const,
|
|
593
|
-
value: canonicalize(
|
|
630
|
+
value: canonicalize(bound),
|
|
594
631
|
},
|
|
595
632
|
},
|
|
596
633
|
needs: {},
|
|
@@ -599,18 +636,20 @@ export function defineExactModelModule<const Key extends string>(
|
|
|
599
636
|
})),
|
|
600
637
|
{
|
|
601
638
|
producer: item.finalizeProducer,
|
|
602
|
-
handler: ({ inputs }) =>
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
639
|
+
handler: ({ inputs }) => {
|
|
640
|
+
const endpoint = endpoints[item.spec.key]!;
|
|
641
|
+
const draft = inlineValue<GenerationRequestDraft>(inputs.draft!.value, `${item.spec.key} draft`);
|
|
642
|
+
endpoint.validateDraft(draft);
|
|
643
|
+
return {
|
|
644
|
+
outputs: {
|
|
645
|
+
request: {
|
|
646
|
+
kind: "inline" as const,
|
|
647
|
+
value: canonicalize(endpoint.sealRequest(draft.ports)),
|
|
648
|
+
},
|
|
610
649
|
},
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
}
|
|
650
|
+
needs: {},
|
|
651
|
+
};
|
|
652
|
+
},
|
|
614
653
|
},
|
|
615
654
|
]),
|
|
616
655
|
plannedNeeds: Object.values(endpoints).map((endpoint) => exactModelPlannedNeedFacet(endpoint)),
|
|
@@ -6,6 +6,8 @@ It does not load Providers, install programs, inspect credentials or select serv
|
|
|
6
6
|
|
|
7
7
|
1. `resolveProjectRoot({ workspaceRoot?, cwd? })` uses the explicit Workspace, otherwise the nearest
|
|
8
8
|
`package.json` above cwd, otherwise cwd itself. Source and Run filenames do not choose the project.
|
|
9
|
+
After selecting the existing directory, it returns its real filesystem path so project and Source
|
|
10
|
+
paths use the same representation. Symlinks do not redirect the preceding parent-project search.
|
|
9
11
|
2. `findRuntimeProfile(projectRoot)` reads only that project's `.hypit/runtime` file and resolves its
|
|
10
12
|
path relative to the project. An entrypoint's explicit `--runtime` overrides this read for that invocation.
|
|
11
13
|
3. The selected Runtime implementation interprets the Profile, including `dataRoot`, Credentials,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { stat } from "node:fs/promises";
|
|
1
|
+
import { realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
async function nearestProjectPackageRoot(start: string): Promise<string | undefined> {
|
|
@@ -28,8 +28,10 @@ export async function resolveProjectRoot(options: {
|
|
|
28
28
|
readonly cwd?: string;
|
|
29
29
|
} = {}): Promise<string> {
|
|
30
30
|
const start = resolve(options.workspaceRoot ?? options.cwd ?? process.cwd());
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
const selected = options.workspaceRoot !== undefined ? start : await nearestProjectPackageRoot(start) ?? start;
|
|
32
|
+
// Select through the caller's directory first; following a link before discovery
|
|
33
|
+
// could choose a different parent project. Source readers also return real paths.
|
|
34
|
+
return await realpath(selected);
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/** Project package discovery cannot escape an already resolved project. */
|
|
@@ -96,11 +96,20 @@ execution earlier. ResourceStore I/O and Surface probes receive the cancellation
|
|
|
96
96
|
ResourceStore must implement the port's cancellation behavior, including streaming reads and writes.
|
|
97
97
|
|
|
98
98
|
Browser launch, source extraction, capture and encoding run in one disposable child process per
|
|
99
|
-
render.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
render. After successful capture closes its resources, the child sends its completion message,
|
|
100
|
+
flushes that message and disconnects IPC so it can exit normally. The owner awaits exit and drains
|
|
101
|
+
diagnostics before returning. Normal completion does not enumerate or forcibly terminate processes.
|
|
102
|
+
On failure, cleanup may be incomplete: the child reports the error and keeps IPC open while the owner
|
|
103
|
+
discovers and terminates the remaining process tree, before it can become orphaned.
|
|
104
|
+
|
|
105
|
+
At cancellation the child receives a stop request and has up to five seconds to clean up. A child
|
|
106
|
+
that remains after cancellation or its completion message is forcibly terminated along with its discovered
|
|
107
|
+
process tree, including Chrome's separate process groups. Cleanup problems are reported through the
|
|
108
|
+
existing diagnostic callback; they do not discard a render already reported as completed. If process
|
|
109
|
+
enumeration fails, the owner still terminates the direct child but cannot confirm descendant cleanup.
|
|
110
|
+
This also covers engine calls that do not accept a signal. The deadline initiates shutdown; the call
|
|
111
|
+
may spend additional time closing resources. Completed Outputs in the Build remain available for a
|
|
112
|
+
new Run and Build.
|
|
104
113
|
|
|
105
114
|
Deployments may additionally set `initializationTimeoutMs` or `frameTimeoutMs` when they have a
|
|
106
115
|
measured stage deadline. Initialization here means initializing an already created browser session;
|
|
@@ -75,6 +75,7 @@ export async function runCaptureProcess(
|
|
|
75
75
|
});
|
|
76
76
|
let failure: Error | undefined;
|
|
77
77
|
let completed = false;
|
|
78
|
+
let closed = false;
|
|
78
79
|
let outputBytes = 0;
|
|
79
80
|
let stderr = "";
|
|
80
81
|
let diagnostics = Promise.resolve();
|
|
@@ -84,14 +85,27 @@ export async function runCaptureProcess(
|
|
|
84
85
|
if (grace !== undefined) clearTimeout(grace);
|
|
85
86
|
killing ??= (child.pid === undefined ? Promise.resolve() : killRenderTree(child.pid)).catch((error) => {
|
|
86
87
|
if (!completed) failure = new Error(`${failure?.message ?? "Render cleanup failed"}; ${String(error)}`);
|
|
88
|
+
diagnostic({ level: "warning", message: `Render process-tree cleanup could not be confirmed: ${String(error)}` });
|
|
87
89
|
child.kill("SIGKILL");
|
|
88
90
|
});
|
|
89
91
|
return killing;
|
|
90
92
|
};
|
|
91
93
|
const stop = (error: Error) => {
|
|
92
94
|
failure ??= error;
|
|
95
|
+
if (closed) return;
|
|
93
96
|
if (child.connected) child.send({ type: "abort", error: failure.message }, () => {});
|
|
94
|
-
|
|
97
|
+
awaitExit();
|
|
98
|
+
};
|
|
99
|
+
const diagnostic = (value: ExecutionDiagnostic) => {
|
|
100
|
+
if (onDiagnostic === undefined) return;
|
|
101
|
+
diagnostics = diagnostics.then(() => onDiagnostic(value))
|
|
102
|
+
.catch((error) => stop(error instanceof Error ? error : new Error(String(error))));
|
|
103
|
+
};
|
|
104
|
+
const awaitExit = () => {
|
|
105
|
+
grace ??= setTimeout(() => {
|
|
106
|
+
diagnostic({ level: "warning", message: `Render process did not exit within ${cleanupMs} ms; terminating its remaining process tree` });
|
|
107
|
+
void kill();
|
|
108
|
+
}, cleanupMs);
|
|
95
109
|
};
|
|
96
110
|
const abort = () => stop(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)));
|
|
97
111
|
signal.addEventListener("abort", abort, { once: true });
|
|
@@ -104,10 +118,7 @@ export async function runCaptureProcess(
|
|
|
104
118
|
pipe?.setEncoding("utf8");
|
|
105
119
|
pipe?.on("data", (text: string) => {
|
|
106
120
|
log(Buffer.from(text), stream === "stderr");
|
|
107
|
-
if (
|
|
108
|
-
diagnostics = diagnostics.then(() => onDiagnostic({ stream, level: "info", message: text.trimEnd() }))
|
|
109
|
-
.catch((error) => stop(error instanceof Error ? error : new Error(String(error))));
|
|
110
|
-
}
|
|
121
|
+
if (text.trim()) diagnostic({ stream, level: "info", message: text.trimEnd() });
|
|
111
122
|
});
|
|
112
123
|
}
|
|
113
124
|
child.on("message", (value: { type: string; event?: HyperframesRenderProgress; error?: string }) => {
|
|
@@ -117,13 +128,20 @@ export async function runCaptureProcess(
|
|
|
117
128
|
stop(new Error(value.error));
|
|
118
129
|
} else if (value.type === "completed" || value.type === "failed") {
|
|
119
130
|
completed = value.type === "completed";
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
131
|
+
if (completed) {
|
|
132
|
+
// Successful capture has closed its resources and will disconnect after this message.
|
|
133
|
+
awaitExit();
|
|
134
|
+
} else {
|
|
135
|
+
failure ??= new Error(value.error);
|
|
136
|
+
// Resource cleanup may itself have failed. Keep the worker alive until
|
|
137
|
+
// its remaining descendants have been discovered and terminated.
|
|
138
|
+
void kill();
|
|
139
|
+
}
|
|
123
140
|
}
|
|
124
141
|
});
|
|
125
142
|
child.on("error", (error) => { failure ??= error; void kill(); });
|
|
126
143
|
child.on("close", () => {
|
|
144
|
+
closed = true;
|
|
127
145
|
if (grace !== undefined) clearTimeout(grace);
|
|
128
146
|
signal.removeEventListener("abort", abort);
|
|
129
147
|
void (killing ?? Promise.resolve()).then(async () => {
|
|
@@ -3,15 +3,34 @@ import type { CaptureInput } from "./capture.js";
|
|
|
3
3
|
|
|
4
4
|
const controller = new AbortController();
|
|
5
5
|
const message = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
6
|
+
const disconnected = () => controller.abort(new Error("Render owner disconnected"));
|
|
7
|
+
const report = (value: object) => {
|
|
8
|
+
if (process.connected) process.send?.(value, undefined, undefined, (error) => { if (error) controller.abort(error); });
|
|
9
|
+
};
|
|
10
|
+
const finish = (value: { type: "completed" } | { type: "failed"; error: string }) => {
|
|
11
|
+
// Failure can leave descendants behind. Keep the owner connected until it has
|
|
12
|
+
// discovered and stopped that tree, rather than orphaning it by exiting first.
|
|
13
|
+
if (value.type === "failed") { report(value); return; }
|
|
14
|
+
// Successful capture has closed its resources. Flush the result before closing our IPC handle;
|
|
15
|
+
// our own disconnect is not a cancellation by the owner.
|
|
16
|
+
process.off("disconnect", disconnected);
|
|
17
|
+
process.off("message", receive);
|
|
18
|
+
if (!process.connected) return;
|
|
19
|
+
process.send?.(value, undefined, undefined, (error) => {
|
|
20
|
+
if (error) { console.error(message(error)); process.exitCode = 1; }
|
|
21
|
+
if (process.connected) process.disconnect();
|
|
22
|
+
});
|
|
23
|
+
};
|
|
6
24
|
controller.signal.addEventListener("abort", () => {
|
|
7
|
-
|
|
25
|
+
report({ type: "stopping", error: message(controller.signal.reason) });
|
|
8
26
|
}, { once: true });
|
|
9
|
-
|
|
27
|
+
const receive = (value: { type: "start"; input: CaptureInput } | { type: "abort"; error: string }) => {
|
|
10
28
|
if (value.type === "abort") { controller.abort(new Error(value.error)); return; }
|
|
11
29
|
void captureStagedVisual(value.input, controller,
|
|
12
|
-
(event) =>
|
|
13
|
-
() =>
|
|
14
|
-
(error) =>
|
|
30
|
+
(event) => report({ type: "progress", event })).then(
|
|
31
|
+
() => finish({ type: "completed" }),
|
|
32
|
+
(error) => finish({ type: "failed", error: message(error) }),
|
|
15
33
|
);
|
|
16
|
-
}
|
|
17
|
-
process.on("
|
|
34
|
+
};
|
|
35
|
+
process.on("message", receive);
|
|
36
|
+
process.on("disconnect", disconnected);
|
|
@@ -118,6 +118,9 @@ Resource identity with the same declared person-reference classification is uplo
|
|
|
118
118
|
cross-Build cache. Seedance visual references can carry `personReference` in their media fields;
|
|
119
119
|
the mapping declares it as a resource-transport field and the upload session receives
|
|
120
120
|
`is_person_reference`, preserving true, false and omission. It stays out of the generation body.
|
|
121
|
+
This covers reference images, reference videos, and first/last frames for every declared Seedance
|
|
122
|
+
variant. Omission remains absent on the wire; HypiHub's upload API currently defaults it to false,
|
|
123
|
+
so omission does not enable detection or person-reference preparation.
|
|
121
124
|
HypiHub stores the authored classification and prepares the applicable upstream person reference;
|
|
122
125
|
this Provider does not detect faces or select an upstream private-avatar group.
|
|
123
126
|
|
|
@@ -41,7 +41,7 @@ function nonNegativeInteger(value: number, subject: string): number {
|
|
|
41
41
|
function projectPath(root: string, path: string): string {
|
|
42
42
|
const absolute = isAbsolute(path) ? resolve(path) : resolve(root, path);
|
|
43
43
|
const relation = relative(resolve(root), absolute);
|
|
44
|
-
assert(relation === "" || (!relation.startsWith(
|
|
44
|
+
assert(relation === "" || (relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation)),
|
|
45
45
|
`Build Result source ${path} is outside project ${resolve(root)}`);
|
|
46
46
|
return (relation || ".").split(sep).join("/");
|
|
47
47
|
}
|
|
@@ -18,6 +18,20 @@ the author's literal, in whole seconds inside the model's declared range; measur
|
|
|
18
18
|
with `hypit measure` and write the number here. Nothing in the graph computes it, so a Build plan is
|
|
19
19
|
complete before it starts.
|
|
20
20
|
|
|
21
|
+
## Reference audio
|
|
22
|
+
|
|
23
|
+
The Seedance package rejects reference audio declared as `audio/mp4` or `audio/x-m4a`. Convert the
|
|
24
|
+
audio to WAV or MP3 before using it; `media:ExtractAudio` produces WAV and can consume an upstream
|
|
25
|
+
component's media output. Renaming a file or changing its declared media type is not conversion.
|
|
26
|
+
|
|
27
|
+
Known imports are checked during Surface decoding. Future audio stays a graph input and is checked
|
|
28
|
+
when its Blob arrives. The same rule applies to drafts, complete requests, planning and direct
|
|
29
|
+
generation Producers. `sealSeedanceRequest` uses the endpoint's model-aware request builder;
|
|
30
|
+
`seedanceComponent` and `seedanceDefinition.component` expose the same implementation.
|
|
31
|
+
|
|
32
|
+
This checks the Blob's declared media type, not its bytes or codec. The selected Provider remains
|
|
33
|
+
responsible for any additional service-specific input limits.
|
|
34
|
+
|
|
21
35
|
## Visual reference metadata
|
|
22
36
|
|
|
23
37
|
Declare whether each image or video contains a person/avatar reference, including an AI-generated
|
|
@@ -37,10 +51,38 @@ Inspect the actual reference when deciding the value. For `FrameVideo`, use
|
|
|
37
51
|
`first-frame-person-reference` and `last-frame-person-reference` beside their respective frame
|
|
38
52
|
inputs. A last-frame classification requires a last-frame input.
|
|
39
53
|
|
|
54
|
+
| Supplied visual input | Authored attribute | Request port |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| Each `Reference image={...}` | `person-reference` | `referenceImage` |
|
|
57
|
+
| Each `Reference video={...}` | `person-reference` | `referenceVideo` |
|
|
58
|
+
| `FrameVideo` first frame | `first-frame-person-reference` | `firstFrame` |
|
|
59
|
+
| `FrameVideo` last frame | `last-frame-person-reference` | `lastFrame` |
|
|
60
|
+
|
|
61
|
+
These forms apply to `standard`, `fast`, `mini` and `2.5`. For example:
|
|
62
|
+
|
|
63
|
+
```xml
|
|
64
|
+
<seedance:FrameVideo id="turn" model="fast" prompt={direction} duration="5"
|
|
65
|
+
first-frame={presenter.image} first-frame-person-reference="true"
|
|
66
|
+
last-frame={empty-room.image} last-frame-person-reference="false"/>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Classify the material supplied to this request, not the intended result. A dance video with a person
|
|
70
|
+
still needs `true` when used only for motion, even if the prompt asks for a different performer.
|
|
71
|
+
An empty room stays `false` when the prompt asks to add a person. Inspect video across the selected
|
|
72
|
+
excerpt, not only its opening frame. This flag neither detects faces nor locks or names an identity.
|
|
73
|
+
Identity and action direction remain in the prompt and references.
|
|
74
|
+
|
|
75
|
+
The SVML author declares this parameter on each reference input. Admitted files, generated
|
|
76
|
+
images/videos and reused Results use the same attributes. For a future output, declare the intended
|
|
77
|
+
reference classification explicitly; if its contents are uncertain, generate and inspect that
|
|
78
|
+
material before using it downstream.
|
|
79
|
+
|
|
40
80
|
The model's media ports carry this as `fields.personReference`. Providers interpret it through their
|
|
41
81
|
service's media handling; it is not a prompt sentence or a Core-level identity. HypiHub sends it as
|
|
42
82
|
`is_person_reference` when uploading the file, then uses the returned URL in the ordinary video
|
|
43
83
|
request. A project Provider maps it according to its own API.
|
|
84
|
+
Omission does not request automatic face detection. HypiHub currently treats omitted upload flags
|
|
85
|
+
as unmarked (`false`); declare `true` explicitly for a person reference that needs its preparation.
|
|
44
86
|
|
|
45
87
|
Video references can carry motion or camera behavior while image references carry the target
|
|
46
88
|
appearance. Request duration and reference-clip duration are different limits. Check the selected
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
sealGenerationPortRequest,
|
|
3
|
-
sealGenerationPortTable,
|
|
4
|
-
} from "@hypit/generation";
|
|
1
|
+
import { sealGenerationPortTable } from "@hypit/generation";
|
|
5
2
|
import type {
|
|
6
3
|
GenerationPortTable,
|
|
7
4
|
GenerationPortValue,
|
|
@@ -12,6 +9,7 @@ import type { SurfaceAttributeVocabulary, SurfacePortVocabulary } from "@hypit/m
|
|
|
12
9
|
import { defineExactModelModule } from "@hypit/model-kit";
|
|
13
10
|
import { textTypes } from "@hypit/text";
|
|
14
11
|
import type { ResourceId } from "@hypit/protocol";
|
|
12
|
+
import { validateSeedanceInputs } from "./validation.js";
|
|
15
13
|
|
|
16
14
|
export const seedanceModuleRef = { name: "@hypit/seedance", version: "1" } as const;
|
|
17
15
|
export const seedanceModels = ["seedance-2", "seedance-2-fast", "seedance-2-mini", "seedance-2.5"] as const;
|
|
@@ -93,7 +91,7 @@ export const seedancePorts: Readonly<Record<SeedanceModel, GenerationPortTable>>
|
|
|
93
91
|
export type SeedancePortMap = Readonly<Record<string, readonly GenerationPortValue[]>>;
|
|
94
92
|
|
|
95
93
|
export function sealSeedanceRequest(model: SeedanceModel, ports: SeedancePortMap): GenerationRequest {
|
|
96
|
-
return
|
|
94
|
+
return seedanceEndpointsByModel[model].sealRequest(ports);
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
const seedanceBaseDefinition = defineExactModelModule({
|
|
@@ -110,6 +108,7 @@ const seedanceBaseDefinition = defineExactModelModule({
|
|
|
110
108
|
: `${model.split("-").map((part) => part[0]!.toUpperCase() + part.slice(1)).join("")}Request`,
|
|
111
109
|
producerName: `request-${model}`,
|
|
112
110
|
ports: seedancePorts[model],
|
|
111
|
+
validateInputs: validateSeedanceInputs,
|
|
113
112
|
})),
|
|
114
113
|
});
|
|
115
114
|
|
|
@@ -327,23 +326,7 @@ export const seedanceMarkupSurfaces = [
|
|
|
327
326
|
/** The duration is an author literal on every Seedance Surface, so the manifest is the exact-model module's own. */
|
|
328
327
|
export const seedanceManifest = seedanceBaseDefinition.manifest;
|
|
329
328
|
|
|
330
|
-
const
|
|
331
|
-
.map((endpoint) => endpoint.mediaBindings["referenceAudio"]!.producer.name));
|
|
332
|
-
|
|
333
|
-
export const seedanceComponent = {
|
|
334
|
-
...seedanceBaseDefinition.component,
|
|
335
|
-
producers: seedanceBaseDefinition.component.producers.map((facet) =>
|
|
336
|
-
referenceAudioProducers.has(facet.producer.name) ? {
|
|
337
|
-
...facet,
|
|
338
|
-
handler: (context: Parameters<typeof facet.handler>[0]) => {
|
|
339
|
-
const artifact = context.inputs.artifact?.value;
|
|
340
|
-
if (artifact?.kind === "blob" && ["audio/mp4", "audio/x-m4a"].includes(artifact.mediaType)) {
|
|
341
|
-
throw new Error("Seedance reference audio does not accept m4a; convert to wav or mp3");
|
|
342
|
-
}
|
|
343
|
-
return facet.handler(context);
|
|
344
|
-
},
|
|
345
|
-
} : facet),
|
|
346
|
-
};
|
|
329
|
+
export const seedanceComponent = seedanceBaseDefinition.component;
|
|
347
330
|
export const seedanceDefinition = seedanceBaseDefinition;
|
|
348
331
|
|
|
349
332
|
export {
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
import { createSeedanceAssembledGenerationFragment } from "./fragment.js";
|
|
24
24
|
import { seedanceEndpoints, seedancePorts } from "./index.js";
|
|
25
25
|
import type { SeedanceModel, SeedancePortMap } from "./index.js";
|
|
26
|
+
import { validateSeedanceAudio } from "./validation.js";
|
|
26
27
|
|
|
27
28
|
type MediaInput = {
|
|
28
29
|
readonly port: "referenceImage" | "referenceVideo" | "referenceAudio" | "firstFrame" | "lastFrame";
|
|
@@ -109,9 +110,7 @@ function mediaReference(
|
|
|
109
110
|
if (value !== undefined && (value.kind !== "blob" || !value.mediaType.startsWith(`${role}/`))) {
|
|
110
111
|
throw new Error(`${subject} must reference ${role} media`);
|
|
111
112
|
}
|
|
112
|
-
if (value
|
|
113
|
-
throw new Error(`${subject} references m4a audio, which Seedance does not accept; convert to wav or mp3 and admit that file`);
|
|
114
|
-
}
|
|
113
|
+
if (value?.kind === "blob" && role === "audio") validateSeedanceAudio(value, subject);
|
|
115
114
|
return reference;
|
|
116
115
|
}
|
|
117
116
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { GenerationRequestDraft } from "@hypit/generation";
|
|
2
|
+
import type { BlobRef } from "@hypit/protocol";
|
|
3
|
+
|
|
4
|
+
/** Seedance's reference-audio rule, shared by known imports and request assembly. */
|
|
5
|
+
export function validateSeedanceAudio(artifact: BlobRef, subject = "Seedance reference audio"): void {
|
|
6
|
+
if (artifact.mediaType === "audio/mp4" || artifact.mediaType === "audio/x-m4a") {
|
|
7
|
+
throw new Error(`${subject} does not accept m4a; convert the reference audio to WAV or MP3 before using it (media:ExtractAudio produces WAV)`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Missing references in a draft remain graph inputs; only supplied audio is checked. */
|
|
12
|
+
export function validateSeedanceInputs(request: GenerationRequestDraft): void {
|
|
13
|
+
for (const value of request.ports.referenceAudio ?? []) {
|
|
14
|
+
if (typeof value === "object" && value.role === "audio") validateSeedanceAudio(value.artifact);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -2,7 +2,7 @@ import { watch } from "node:fs";
|
|
|
2
2
|
import type { FSWatcher } from "node:fs";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { readFile } from "node:fs/promises";
|
|
5
|
-
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { Readable } from "node:stream";
|
|
7
7
|
import { pipeline } from "node:stream/promises";
|
|
8
8
|
|
|
@@ -207,7 +207,7 @@ export function studioPlugin(options: StudioPluginOptions): Plugin {
|
|
|
207
207
|
if (isAbsolute(patch.path)) throw new Error("Studio patches must use workspace-relative paths.");
|
|
208
208
|
const absolute = resolve(options.workspaceRoot, patch.path);
|
|
209
209
|
const rel = relative(options.workspaceRoot, absolute);
|
|
210
|
-
if (rel.startsWith(
|
|
210
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel) || !allowedSourceFiles.has(absolute)) {
|
|
211
211
|
throw new Error(`Studio cannot write source file ${patch.path}.`);
|
|
212
212
|
}
|
|
213
213
|
if (!Number.isInteger(patch.range.start) || !Number.isInteger(patch.range.end)
|
|
@@ -2,7 +2,7 @@ import { pathToFileURL } from "node:url";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { createReadStream } from "node:fs";
|
|
4
4
|
import { readFile, realpath, stat } from "node:fs/promises";
|
|
5
|
-
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
6
6
|
|
|
7
7
|
import type {
|
|
8
8
|
Awaitable,
|
|
@@ -20,7 +20,7 @@ import type { BlobRef } from "@hypit/protocol";
|
|
|
20
20
|
|
|
21
21
|
function isWithin(root: string, path: string): boolean {
|
|
22
22
|
const relation = relative(root, path);
|
|
23
|
-
return relation === "" || (!relation.startsWith(
|
|
23
|
+
return relation === "" || (relation !== ".." && !relation.startsWith(`..${sep}`) && !isAbsolute(relation));
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export type NodeFilesystemExternalSource = {
|