@sema-agent/settings-schema 11.2.0 → 11.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/README.md +2 -1
- package/conformance/VERSION +1 -1
- package/conformance/cases/c73-projects-extended-registration-reads/expected.json +150 -0
- package/conformance/cases/c73-projects-extended-registration-reads/input.json +42 -0
- package/conformance/cases/c73-projects-extended-registration-reads/meta.json +6 -0
- package/conformance/cases/c74-projects-over-bound-refused/expected.json +89 -0
- package/conformance/cases/c74-projects-over-bound-refused/input.json +29 -0
- package/conformance/cases/c74-projects-over-bound-refused/meta.json +5 -0
- package/conformance/cases/c75-projects-patch-remote-uniqueness/expected.json +89 -0
- package/conformance/cases/c75-projects-patch-remote-uniqueness/input.json +35 -0
- package/conformance/cases/c75-projects-patch-remote-uniqueness/meta.json +5 -0
- package/dist/api/feedback.js +2 -3
- package/dist/api/scopes.js +2 -1
- package/dist/atomic-write.d.ts +6 -0
- package/dist/atomic-write.js +12 -4
- package/dist/cli.d.ts +7 -4
- package/dist/cli.js +52 -10
- package/dist/config-d-file.d.ts +11 -0
- package/dist/config-d-file.js +35 -1
- package/dist/config-dir-io.js +4 -3
- package/dist/config-dir-migration.d.ts +5 -2
- package/dist/config-dir-migration.js +23 -9
- package/dist/cross-domain.d.ts +8 -3
- package/dist/cross-domain.js +23 -13
- package/dist/file-edit.js +7 -5
- package/dist/file-store.js +2 -1
- package/dist/fleet.js +3 -4
- package/dist/hooks.d.ts +14 -1
- package/dist/hooks.js +61 -16
- package/dist/host-path.d.ts +17 -0
- package/dist/host-path.js +22 -0
- package/dist/integer.js +2 -1
- package/dist/local-load.d.ts +1 -1
- package/dist/local-load.js +9 -31
- package/dist/migration-receipt.d.ts +3 -2
- package/dist/node.d.ts +1 -1
- package/dist/one-time-migrations.d.ts +5 -3
- package/dist/one-time-migrations.js +19 -6
- package/dist/quoted-issue.d.ts +41 -0
- package/dist/quoted-issue.js +35 -0
- package/dist/remote-exec.d.ts +3 -0
- package/dist/remote-exec.js +12 -7
- package/dist/rfc3339.d.ts +10 -0
- package/dist/rfc3339.js +12 -0
- package/dist/types.d.ts +140 -20
- package/dist/types.js +194 -105
- package/dist/web-admin-credential.d.ts +2 -2
- package/dist/web-admin-credential.js +5 -10
- package/package.json +1 -1
package/dist/hooks.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { z } from "zod";
|
|
32
32
|
import { INTEGER } from "./integer.js";
|
|
33
|
+
import { buildQuotedIssue } from "./quoted-issue.js";
|
|
33
34
|
// ── event set ──────────────────────────────────────────────────────────────────────────────────────────
|
|
34
35
|
// The two halves below are where each word came from (CC 2.1.198's own table, then the later additions) — provenance for the
|
|
35
36
|
// reader of this file, not a contract: they are module-private, and the ONE exported closed set is {@link HOOK_EVENTS}.
|
|
@@ -623,7 +624,7 @@ export const HOOK_INJECTED_ENV = [
|
|
|
623
624
|
/** Default per-hook timeout when an entry does not set one (seconds; CC default). */
|
|
624
625
|
export const DEFAULT_HOOK_TIMEOUT_SECONDS = 60;
|
|
625
626
|
/** A non-empty string with no NUL byte (spawn()/JSON-safe). */
|
|
626
|
-
const hookStr = z.string().min(1).refine((s) => !s.includes("\0"),
|
|
627
|
+
const hookStr = z.string().min(1).refine((s) => !s.includes("\0"), buildQuotedIssue `must not contain a NUL byte`);
|
|
627
628
|
/**
|
|
628
629
|
* `if` condition — permission-rule syntax (e.g. "Bash(git *)") evaluated against tool_name/tool_input
|
|
629
630
|
* BEFORE spawning the hook (only meaningful on tool events). Shell superset field, CC-compatible.
|
|
@@ -684,6 +685,25 @@ export const HookMatcherEntry = z.object({
|
|
|
684
685
|
});
|
|
685
686
|
/** The characters a matcher may consist of to be read as a NAME LIST rather than a regex. */
|
|
686
687
|
const HOOK_MATCHER_NAME_LIST = /^[a-zA-Z0-9_|, -]+$/;
|
|
688
|
+
function readHookMatcher(matcher) {
|
|
689
|
+
if (matcher === undefined || matcher === "" || matcher === "*")
|
|
690
|
+
return { form: "all" };
|
|
691
|
+
if (HOOK_MATCHER_NAME_LIST.test(matcher)) {
|
|
692
|
+
return {
|
|
693
|
+
form: "names",
|
|
694
|
+
names: matcher
|
|
695
|
+
.split(/[|,]/)
|
|
696
|
+
.map((part) => part.trim())
|
|
697
|
+
.filter((part) => part !== ""),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
return { form: "regex", pattern: new RegExp(matcher) };
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
return { form: "invalid" };
|
|
705
|
+
}
|
|
706
|
+
}
|
|
687
707
|
/**
|
|
688
708
|
* Does `matcher` select `name`? —— **the one reading** of a hooks matcher (11.0.0). The shell and the server both ask this
|
|
689
709
|
* function; neither keeps a copy.
|
|
@@ -703,25 +723,50 @@ const HOOK_MATCHER_NAME_LIST = /^[a-zA-Z0-9_|, -]+$/;
|
|
|
703
723
|
* for the bad matcher itself.
|
|
704
724
|
* Before 11.0.0 the server anchored the regex form (`^(?:…)$`) while the shell did not, so one regex matcher selected different
|
|
705
725
|
* tools in the two places. A deployment whose regex matchers relied on that anchoring selects MORE tools now — add `^…$`.
|
|
706
|
-
* Pure: no budget / timeout — a caller evaluating untrusted regex matchers under a deadline does so around this call
|
|
726
|
+
* Pure: no budget / timeout — a caller evaluating untrusted regex matchers under a deadline does so around this call
|
|
727
|
+
* ({@link hookMatcherForm} tells it beforehand which matchers run a regex at all).
|
|
707
728
|
*/
|
|
708
729
|
export function hookMatcherSelects(matcher, name) {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
730
|
+
const r = readHookMatcher(matcher);
|
|
731
|
+
switch (r.form) {
|
|
732
|
+
case "all":
|
|
733
|
+
return true;
|
|
734
|
+
case "names":
|
|
735
|
+
return r.names.includes(name);
|
|
736
|
+
case "regex":
|
|
737
|
+
return r.pattern.test(name);
|
|
738
|
+
case "invalid":
|
|
739
|
+
return "invalid";
|
|
740
|
+
default: {
|
|
741
|
+
const unhandled = r;
|
|
742
|
+
throw new Error(`hookMatcherSelects: unhandled matcher reading ${JSON.stringify(unhandled)}`);
|
|
743
|
+
}
|
|
720
744
|
}
|
|
721
|
-
|
|
722
|
-
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Which form `matcher` takes under the one reading —— the same parse step as {@link hookMatcherSelects}, so for every
|
|
748
|
+
* matcher `hookMatcherForm(m) === "invalid"` ⇔ `hookMatcherSelects(m, name) === "invalid"` (for any `name`):
|
|
749
|
+
* · `"literal"` —— no regex is compiled or run: absent / `""` / `"*"` (selects everything) and the NAME-LIST form (only name
|
|
750
|
+
* characters, `|`, `,` and spaces — membership after splitting);
|
|
751
|
+
* · `"regex"` —— anything else that compiles as a JS regex (tested unanchored);
|
|
752
|
+
* · `"invalid"` —— a regex that does not compile (the caller does not run the hook).
|
|
753
|
+
* For a caller that must route matchers before evaluating them (run the literal ones inline, give the regex ones a deadline).
|
|
754
|
+
*/
|
|
755
|
+
export function hookMatcherForm(matcher) {
|
|
756
|
+
const r = readHookMatcher(matcher);
|
|
757
|
+
switch (r.form) {
|
|
758
|
+
case "all":
|
|
759
|
+
case "names":
|
|
760
|
+
return "literal";
|
|
761
|
+
case "regex":
|
|
762
|
+
return "regex";
|
|
763
|
+
case "invalid":
|
|
764
|
+
return "invalid";
|
|
765
|
+
default: {
|
|
766
|
+
const unhandled = r;
|
|
767
|
+
throw new Error(`hookMatcherForm: unhandled matcher reading ${JSON.stringify(unhandled)}`);
|
|
768
|
+
}
|
|
723
769
|
}
|
|
724
|
-
return pattern.test(name);
|
|
725
770
|
}
|
|
726
771
|
export const HooksConfig = z.partialRecord(HookEventName, z.array(HookMatcherEntry));
|
|
727
772
|
/**
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 叶子模块(纯、浏览器安全):**一只**「主机绝对路径」形判 —— 任务请求的 `cwd`(服务器受理时判)与项目登记里 `folder` / `file`
|
|
3
|
+
* 条目的值读同一只,不各写一份。只判形,不判存在(不碰文件系统)。
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
/** 一条主机绝对路径的长度上界(字符数)。 */
|
|
7
|
+
export declare const ABSOLUTE_HOST_PATH_MAX = 4096;
|
|
8
|
+
/**
|
|
9
|
+
* `p` 是不是一条形上合法的**主机绝对路径**:字符串、非空、≤ {@link ABSOLUTE_HOST_PATH_MAX}、不含 NUL、以 `/` 开头(posix 绝对)、没有 `..` 段
|
|
10
|
+
* (`/` 与 `\` 都算分隔符 —— `path.isAbsolute` 是语法判,`/a/../../etc` 也「绝对」,而读方 `path.resolve` 会把它解到别处;拒掉它,读到的就是写下的那一条)。
|
|
11
|
+
*
|
|
12
|
+
* posix 口径是有意的:读这些路径的主机车道今天跑 posix 树,而这一律在 posix 宿主上与 Node 的 `path.isAbsolute` 逐字同判(`C:\x`、`\\host\share`
|
|
13
|
+
* 在 posix 上不绝对)。哪天主机车道要接 win 树,换的是这一只(按树的家族判),读方跟着同一只走。
|
|
14
|
+
*/
|
|
15
|
+
export declare function isAbsoluteHostPath(p: unknown): p is string;
|
|
16
|
+
/** {@link isAbsoluteHostPath} 的 zod 座位(一条 issue,定句)。 */
|
|
17
|
+
export declare const AbsoluteHostPath: z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 叶子模块(纯、浏览器安全):**一只**「主机绝对路径」形判 —— 任务请求的 `cwd`(服务器受理时判)与项目登记里 `folder` / `file`
|
|
3
|
+
* 条目的值读同一只,不各写一份。只判形,不判存在(不碰文件系统)。
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { buildQuotedIssue, fixed } from "./quoted-issue.js";
|
|
7
|
+
/** 一条主机绝对路径的长度上界(字符数)。 */
|
|
8
|
+
export const ABSOLUTE_HOST_PATH_MAX = 4096;
|
|
9
|
+
/**
|
|
10
|
+
* `p` 是不是一条形上合法的**主机绝对路径**:字符串、非空、≤ {@link ABSOLUTE_HOST_PATH_MAX}、不含 NUL、以 `/` 开头(posix 绝对)、没有 `..` 段
|
|
11
|
+
* (`/` 与 `\` 都算分隔符 —— `path.isAbsolute` 是语法判,`/a/../../etc` 也「绝对」,而读方 `path.resolve` 会把它解到别处;拒掉它,读到的就是写下的那一条)。
|
|
12
|
+
*
|
|
13
|
+
* posix 口径是有意的:读这些路径的主机车道今天跑 posix 树,而这一律在 posix 宿主上与 Node 的 `path.isAbsolute` 逐字同判(`C:\x`、`\\host\share`
|
|
14
|
+
* 在 posix 上不绝对)。哪天主机车道要接 win 树,换的是这一只(按树的家族判),读方跟着同一只走。
|
|
15
|
+
*/
|
|
16
|
+
export function isAbsoluteHostPath(p) {
|
|
17
|
+
return typeof p === "string" && p.length > 0 && p.length <= ABSOLUTE_HOST_PATH_MAX && !p.includes("\0") && p.startsWith("/") && !p.split(/[/\\]/).includes("..");
|
|
18
|
+
}
|
|
19
|
+
/** {@link isAbsoluteHostPath} 的 zod 座位(一条 issue,定句)。 */
|
|
20
|
+
export const AbsoluteHostPath = z
|
|
21
|
+
.string()
|
|
22
|
+
.refine(isAbsoluteHostPath, buildQuotedIssue `must be an absolute host path: starts with "/", no ".." segment, no NUL, at most ${fixed(String(ABSOLUTE_HOST_PATH_MAX))} characters`);
|
package/dist/integer.js
CHANGED
|
@@ -8,4 +8,5 @@
|
|
|
8
8
|
* written down here once, instead of being borrowed from the library and changing whenever the library does.
|
|
9
9
|
*/
|
|
10
10
|
import { z } from "zod";
|
|
11
|
-
|
|
11
|
+
import { buildQuotedIssue } from "./quoted-issue.js";
|
|
12
|
+
export const INTEGER = z.number().refine((n) => Number.isInteger(n), buildQuotedIssue `Expected integer, received float`);
|
package/dist/local-load.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type RequiredEnvName } from "./file-edit.js";
|
|
2
2
|
import { type EffectiveReadWarning } from "./config-fns.js";
|
|
3
|
-
import { RemoteExecSpec } from "./remote-exec.js";
|
|
3
|
+
import { type RemoteExecSpec } from "./remote-exec.js";
|
|
4
4
|
import type { EffectiveConfig } from "./types.js";
|
|
5
5
|
/** The remoteExec file path under a config root (under config.d/, beside the per-domain files). */
|
|
6
6
|
export declare function remoteExecPath(root: string): string;
|
package/dist/local-load.js
CHANGED
|
@@ -13,25 +13,12 @@ import path from "node:path";
|
|
|
13
13
|
import { writeFileAtomic } from "./atomic-write.js";
|
|
14
14
|
import { FileConfigStore } from "./file-store.js";
|
|
15
15
|
import { listRequiredEnvNames } from "./file-edit.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
* ride the thrown error). A hostile getter/Proxy trap → a fixed message. R14. */
|
|
20
|
-
function parseRemoteExec(raw) {
|
|
21
|
-
let r;
|
|
22
|
-
try {
|
|
23
|
-
r = RemoteExecSpec.safeParse(raw);
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
throw new Error("invalid remoteExec (a value could not be read during validation)");
|
|
27
|
-
}
|
|
28
|
-
if (r.success)
|
|
29
|
-
return r.data;
|
|
30
|
-
throw sanitizeZodError(r.error);
|
|
31
|
-
}
|
|
16
|
+
import {} from "./config-fns.js";
|
|
17
|
+
import { configDDocumentText, judgeRemoteExecText, parseRemoteExecDocument } from "./config-d-file.js";
|
|
18
|
+
import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
|
|
32
19
|
/** The remoteExec file path under a config root (under config.d/, beside the per-domain files). */
|
|
33
20
|
export function remoteExecPath(root) {
|
|
34
|
-
return path.join(root, "config.d",
|
|
21
|
+
return path.join(root, "config.d", `${REMOTE_EXEC_DOCUMENT}.json`);
|
|
35
22
|
}
|
|
36
23
|
/** Read `<root>/config.d/remote-exec.json` → a VALIDATED RemoteExecSpec, or undefined if the file is absent
|
|
37
24
|
* (→ the engine defaults to `host` under CONFIG_PROVIDER=local). Throws (zod) on a present-but-invalid file. */
|
|
@@ -45,28 +32,19 @@ export async function loadRemoteExec(root) {
|
|
|
45
32
|
return undefined;
|
|
46
33
|
throw e;
|
|
47
34
|
}
|
|
48
|
-
const trimmed = text.trim();
|
|
49
35
|
// A present-but-empty file is a corruption/half-write, NOT "use the default" — FAIL CLOSED (an empty file
|
|
50
36
|
// silently falling back to the host lane would be an isolation DOWNGRADE if the user meant local-docker).
|
|
51
|
-
// To get the host default, the file must be ABSENT, not empty.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
let json;
|
|
55
|
-
try {
|
|
56
|
-
json = JSON.parse(trimmed); // sanitized: don't let a SyntaxError echo the raw (possibly secret-bearing) file text
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
throw new Error(`${remoteExecPath(root)} — invalid JSON (delete it for the host default, or write a valid remoteExec spec)`);
|
|
60
|
-
}
|
|
61
|
-
return parseRemoteExec(json);
|
|
37
|
+
// To get the host default, the file must be ABSENT, not empty. The text judge and the sanitized value judge are the ones the
|
|
38
|
+
// directory migration reads this file with (`./config-d-file`).
|
|
39
|
+
return parseRemoteExecDocument(judgeRemoteExecText(remoteExecPath(root), text));
|
|
62
40
|
}
|
|
63
41
|
/** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (the package's one atomic writer, `./atomic-write`; never a half file). The
|
|
64
42
|
* client's `config` editor writes the exec backend through this; the engine reads it via loadRemoteExec. */
|
|
65
43
|
export async function writeRemoteExec(root, spec) {
|
|
66
|
-
const parsed =
|
|
44
|
+
const parsed = parseRemoteExecDocument(spec);
|
|
67
45
|
const dir = path.join(root, "config.d");
|
|
68
46
|
await mkdir(dir, { recursive: true });
|
|
69
|
-
writeFileAtomic(remoteExecPath(root),
|
|
47
|
+
writeFileAtomic(remoteExecPath(root), configDDocumentText(parsed), "config-document");
|
|
70
48
|
return parsed;
|
|
71
49
|
}
|
|
72
50
|
/** Resolve a `file:<path>` .env VALUE → the file's trimmed content (the file-NAME secret ref, config-research
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* 一次性迁移的**收据形**与两张闭集(11.0.0)—— 叶子模块(运行期零依赖;只有型导入),由 `./one-time-migrations` 再导出(不进主桶)。
|
|
3
3
|
* 单拆出来只为一件事:调度店的读口(`./scheduler-store`,轻、浏览器安全)要在告警里带收据,不该为一个型把整套域 schema 拉进来。
|
|
4
4
|
*/
|
|
5
|
+
import type { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
|
|
5
6
|
import type { DomainName, RetiredDomainName } from "./types.js";
|
|
6
7
|
/** 本版登记的一次性**数据**迁移 id(闭集;每个 id 在 `ONE_TIME_MIGRATIONS` 登记表里恰一行)。收据只许这些 id —— 同表登记的 wire 删码位
|
|
7
8
|
* (`WIRE_RETIREMENT_IDS`,`./one-time-migrations`)不改任何存储形,铸不出收据,不在本集。 */
|
|
@@ -17,8 +18,8 @@ export type OneTimeMigrationId = (typeof ONE_TIME_MIGRATION_IDS)[number];
|
|
|
17
18
|
*/
|
|
18
19
|
export declare const MIGRATION_APPLIED_AS: readonly ["moved", "rewritten", "dropped-shadowed", "dropped-inert", "refused"];
|
|
19
20
|
export type MigrationAppliedAs = (typeof MIGRATION_APPLIED_AS)[number];
|
|
20
|
-
/** 收据指向的那份文档:配置域名、retired 域名(`runtime`),或调度店(`scheduler-store`)。 */
|
|
21
|
-
export type MigrationDocument = DomainName | RetiredDomainName | "scheduler-store";
|
|
21
|
+
/** 收据指向的那份文档:配置域名、retired 域名(`runtime`)、`config.d/remote-exec.json`(`remote-exec`),或调度店(`scheduler-store`)。 */
|
|
22
|
+
export type MigrationDocument = DomainName | RetiredDomainName | typeof REMOTE_EXEC_DOCUMENT | "scheduler-store";
|
|
22
23
|
/** 文档里的段坐标(字符串段 = 对象键,数字段 = 数组下标;与告警坐标同一形)。 */
|
|
23
24
|
export type MigrationPath = readonly (string | number)[];
|
|
24
25
|
interface ReceiptBase {
|
package/dist/node.d.ts
CHANGED
|
@@ -10,5 +10,5 @@ export * from "./bundle.js";
|
|
|
10
10
|
export * from "./local-load.js";
|
|
11
11
|
export * from "./scheduler-store-node.js";
|
|
12
12
|
export * from "./prompts-digest.js";
|
|
13
|
-
export { migrateConfigDirectory, type ConfigDirectoryIo, type ConfigDirectoryMigration, type ConfigDirectoryRefusal, type MigrateConfigDirectoryOptions } from "./config-dir-migration.js";
|
|
13
|
+
export { migrateConfigDirectory, type ConfigDirectoryDocument, type ConfigDirectoryIo, type ConfigDirectoryMigration, type ConfigDirectoryRefusal, type MigrateConfigDirectoryOptions } from "./config-dir-migration.js";
|
|
14
14
|
export { nodeConfigDirectoryIo } from "./config-dir-io.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MigrationReceipt, OneTimeMigrationId } from "./migration-receipt.js";
|
|
2
|
+
import { REMOTE_EXEC_DOCUMENT } from "./remote-exec.js";
|
|
2
3
|
import { type DomainName, type RetiredDomainName } from "./types.js";
|
|
3
4
|
export { MIGRATION_APPLIED_AS, ONE_TIME_MIGRATION_IDS, type MigrationAppliedAs, type MigrationDocument, type MigrationPath, type MigrationReceipt, type OneTimeMigrationId } from "./migration-receipt.js";
|
|
4
5
|
export { migrateSchedulerRecords } from "./scheduler-store-migration.js";
|
|
@@ -33,8 +34,8 @@ export declare const ONE_TIME_MIGRATIONS: readonly OneTimeMigrationWindow[];
|
|
|
33
34
|
/** 一组存储文档是不是**一个 scope 的全集**:`"scope"` = 缺席的域就是从未设过;`"partial"` = 缺席的域只是没给(principal 层、回滚史的
|
|
34
35
|
* 单域行、任意子集)。只有 SS-M1 读它。 */
|
|
35
36
|
export type DocumentCoverage = "scope" | "partial";
|
|
36
|
-
/** 一组存储文档(域名 / retired 域名 → 文档)。别的键原样带过、不动。 */
|
|
37
|
-
export type StoredDocuments = Readonly<Partial<Record<DomainName | RetiredDomainName, unknown>>>;
|
|
37
|
+
/** 一组存储文档(域名 / retired 域名 / `remote-exec` → 文档)。别的键原样带过、不动。 */
|
|
38
|
+
export type StoredDocuments = Readonly<Partial<Record<DomainName | RetiredDomainName | typeof REMOTE_EXEC_DOCUMENT, unknown>>>;
|
|
38
39
|
export interface StoredDocumentsMigration {
|
|
39
40
|
/** 迁移后的文档集:没改的文档是**同一引用**;被移除的(retired 域)不在;新建的(例:迁出的 `governance` / `limits`)在。 */
|
|
40
41
|
readonly docs: Readonly<Record<string, unknown>>;
|
|
@@ -58,5 +59,6 @@ export declare function migrateEffectiveSnapshot(effective: unknown): {
|
|
|
58
59
|
readonly effective: unknown;
|
|
59
60
|
readonly receipts: readonly MigrationReceipt[];
|
|
60
61
|
};
|
|
61
|
-
/**
|
|
62
|
+
/** SS-M7 走查的全部 URL 座位的坐标模式(派生;数组 / Record 位写 `*`;域文档以域名起头,`remote-exec.json` 以 `remote-exec` 起头)—— 迁移不用它,
|
|
63
|
+
* 它给钉与说明看「今天派生出哪些座位」。 */
|
|
62
64
|
export declare function urlSeatPatterns(): string[];
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* 同一串字节换意思的改动一律**换表示**(SS-M6 因此是「`atModelAllowlist` → `atMention`」换字段,而不是翻 `[]` 的义)。
|
|
13
13
|
*
|
|
14
14
|
* ## 入口(纯函数:不读环境、不读墙钟、不改入参、**不抬任何版本号**)
|
|
15
|
-
* · {@link migrateStoredDocuments} —— 一组**存储文档**(域名 →
|
|
16
|
-
* skills / plugins)、回滚史里的一条单域行,各调一次;`coverage` 说这组文档是不是一个 scope 的全集(只有 SS-M1 要知道:缺席的
|
|
15
|
+
* · {@link migrateStoredDocuments} —— 一组**存储文档**(域名 → 文档;还有本机的 `remote-exec` —— `config.d/remote-exec.json`,
|
|
16
|
+
* 只有 SS-M7 的座位走查读它)。一个 scope 的全部草稿、一个 principal 层(个人 models / mcp / skills / plugins)、回滚史里的一条单域行,各调一次;`coverage` 说这组文档是不是一个 scope 的全集(只有 SS-M1 要知道:缺席的
|
|
17
17
|
* `governance` 是「从未设过」还是「没给」)。缺的域 = 不动。
|
|
18
18
|
* · {@link migrateEffectiveSnapshot} —— 一份**冻结的 effective**(发布快照、回滚历史里的整份快照、引擎的 last-known-good 文件)。改写规则
|
|
19
19
|
* 与草稿同(零语义变 ⇒ 不越过发布审批:改的是表示,不是已批准的内容);不从草稿重建;`version` / `updatedAt` 与其余信封键原样。
|
|
@@ -34,6 +34,7 @@ import { LANE_ENV_OWNER, STORE_ENV_KEYS, isLaneEnvKey } from "./execution-lane-i
|
|
|
34
34
|
import { EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, STORE_BACKENDS } from "./execution-lanes.js";
|
|
35
35
|
import { INTEGER } from "./integer.js";
|
|
36
36
|
import { RUNTIME_LIFT_TARGETS } from "./runtime-lift-targets.js";
|
|
37
|
+
import { REMOTE_EXEC_DOCUMENT, RemoteExecSpec } from "./remote-exec.js";
|
|
37
38
|
import { DOMAIN_SCHEMAS, HOSTS_ARRAY_MAX, HOST_NAME_MAX, NON_SECRET_URL, isDomainName, isRetiredDomainName } from "./types.js";
|
|
38
39
|
import { workerRowVerdict } from "./worker-row-verdict.js";
|
|
39
40
|
import { unwrapSchema } from "./zod-unwrap.js";
|
|
@@ -128,7 +129,8 @@ export const ONE_TIME_MIGRATIONS = [
|
|
|
128
129
|
removableAfter: "2026-11-09",
|
|
129
130
|
rewrites: "every NON_SECRET_URL seat in a domain document (derived from the domain schemas, not listed by hand — today models.models[].baseUrl, " +
|
|
130
131
|
"mcp.servers[].transport.url, execution.sessionMirror.engineUrl, workers.workers[].coords.gitApiBaseUrl): `#` and everything after it are removed " +
|
|
131
|
-
"(a fragment never goes on the wire)"
|
|
132
|
+
"(a fragment never goes on the wire); the same walk covers config.d/remote-exec.json, derived from its schema the same way " +
|
|
133
|
+
"(today the k8s apiUrl and s3Snapshot.endpoint) — the command-line migrate reads and rewrites it as one more document",
|
|
132
134
|
removal: "the next major after the window: delete the SS-M7 step and its schema walk; the NON_SECRET_URL fragment rule loses its SS-M7 clause",
|
|
133
135
|
afterRemoval: "the NON_SECRET_URL shape judge, which refuses a fragment (`#` included) on read and write",
|
|
134
136
|
},
|
|
@@ -694,6 +696,14 @@ function stepModelsDefault(out, receipts) {
|
|
|
694
696
|
out.models = { ...without(doc, "default"), roles: { ...roles, default: { model: name } } };
|
|
695
697
|
}
|
|
696
698
|
// ── SS-M7:URL fragment ────────────────────────────────────────────────────────────────────────────────
|
|
699
|
+
/** SS-M7 走查的文档 → 它的 schema(座位从 schema 派生的那一只来源):配置域按 `DOMAIN_SCHEMAS`,`remote-exec` 按 `RemoteExecSpec`;别的文档没有 URL 座位。 */
|
|
700
|
+
function urlSeatSchemaOf(document) {
|
|
701
|
+
if (isDomainName(document))
|
|
702
|
+
return DOMAIN_SCHEMAS[document];
|
|
703
|
+
if (document === REMOTE_EXEC_DOCUMENT)
|
|
704
|
+
return RemoteExecSpec;
|
|
705
|
+
return undefined;
|
|
706
|
+
}
|
|
697
707
|
/** 这只(已展开的)schema 是不是一个 `NON_SECRET_URL` 座位:就是它,或是「先归一、再交给它」的管道(`z.string().trim().pipe(NON_SECRET_URL)`)。 */
|
|
698
708
|
function isUrlSeat(s) {
|
|
699
709
|
return s === NON_SECRET_URL || (s instanceof z.ZodPipe && unwrapSchema(s.out) === NON_SECRET_URL);
|
|
@@ -775,7 +785,8 @@ function stripFragments(schema, value, path, onStrip, depth = 0) {
|
|
|
775
785
|
}
|
|
776
786
|
return value;
|
|
777
787
|
}
|
|
778
|
-
/**
|
|
788
|
+
/** SS-M7 走查的全部 URL 座位的坐标模式(派生;数组 / Record 位写 `*`;域文档以域名起头,`remote-exec.json` 以 `remote-exec` 起头)—— 迁移不用它,
|
|
789
|
+
* 它给钉与说明看「今天派生出哪些座位」。 */
|
|
779
790
|
export function urlSeatPatterns() {
|
|
780
791
|
const out = [];
|
|
781
792
|
const walk = (schema, path, depth) => {
|
|
@@ -813,16 +824,18 @@ export function urlSeatPatterns() {
|
|
|
813
824
|
};
|
|
814
825
|
for (const [d, schema] of Object.entries(DOMAIN_SCHEMAS))
|
|
815
826
|
walk(schema, [d], 0);
|
|
827
|
+
walk(RemoteExecSpec, [REMOTE_EXEC_DOCUMENT], 0);
|
|
816
828
|
return out;
|
|
817
829
|
}
|
|
818
830
|
function stepUrlFragments(out, receipts) {
|
|
819
831
|
for (const d of Object.keys(out)) {
|
|
820
|
-
|
|
832
|
+
const schema = urlSeatSchemaOf(d);
|
|
833
|
+
if (schema === undefined)
|
|
821
834
|
continue;
|
|
822
835
|
const doc = out[d];
|
|
823
836
|
if (doc === undefined)
|
|
824
837
|
continue;
|
|
825
|
-
const next = stripFragments(
|
|
838
|
+
const next = stripFragments(schema, doc, [], (path) => receipts.push(receipt("SS-M7", d, path, "rewritten")));
|
|
826
839
|
if (next !== doc)
|
|
827
840
|
out[d] = next;
|
|
828
841
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包内私有叶子模块(运行期零依赖,浏览器安全):**custom issue 的唯一铸点**(全包一只;11.0.0 时它住在 `./types` 里、只管那一个文件)。
|
|
3
|
+
*
|
|
4
|
+
* 律(一条):本包 schema 自己铸的每一条 custom issue —— `ctx.addIssue({ code: "custom", … })`、`.refine(判据, …)`、引用完整性的 `RefIssue`
|
|
5
|
+
* —— 都带 {@link QuotedIssueParams}(句子的固定词 `template` 与值 `operands`,值位是洞),`message` 恒是 {@link renderQuotedSentence} 从它渲出的,
|
|
6
|
+
* 铸点不手写第二遍。读者因此对 custom issue 只有一种读法:读结构化的句子(日志边界逐值打指纹、诊断面把值换成洞),不在「带 params 的读 params、
|
|
7
|
+
* 不带的退回 message」之间分流。没有值的定句一样走这里(`operands` 为空)。源码门在测试 custom-issue-params(扫 `src/` 全部铸点)。
|
|
8
|
+
*
|
|
9
|
+
* 不在本律里的(如实):zod 内建码的 issue —— `.max(n, 句)` / `.regex(re, 句)` 的 `too_big` / `invalid_format`、墓碑字段 `z.never` 的 `invalid_type`、
|
|
10
|
+
* 联合的错误映射 —— 它们的码不是 `custom`,`params` 不是 zod 给它们的槽位;它们的句子是定句(不插配置里的值),读者读 `message`。
|
|
11
|
+
*/
|
|
12
|
+
/** The params of a custom issue whose sentence QUOTES values (S-1075, 11.0.0) —— plain data: the sentence's fixed parts and the
|
|
13
|
+
* values between them, in order, so `message` is exactly {@link renderQuotedSentence} of them. A reader that has to treat the
|
|
14
|
+
* user-written parts of a sentence differently from its fixed words (a log boundary fingerprinting each value, the sanitizer
|
|
15
|
+
* bounding each value's length) reads them HERE instead of guessing where a value starts in the sentence. Every custom issue this
|
|
16
|
+
* package raises carries them (the fixed sentences too — those carry no operands). */
|
|
17
|
+
export interface QuotedIssueParams {
|
|
18
|
+
readonly template: readonly string[];
|
|
19
|
+
readonly operands: readonly (string | number)[];
|
|
20
|
+
}
|
|
21
|
+
/** The one rendering of a quoted sentence: `template[0] + show(operands[0]) + template[1] + … + template[n]`. `show` defaults to
|
|
22
|
+
* the value as written (that is how `message` is minted); a sanitizer / log boundary passes its own. */
|
|
23
|
+
export declare function renderQuotedSentence(p: QuotedIssueParams, show?: (operand: string | number) => string): string;
|
|
24
|
+
/** A custom issue's quoted sentence and its params (what {@link buildQuotedIssue} mints; spread into `ctx.addIssue`, or passed as a
|
|
25
|
+
* `.refine` second argument). */
|
|
26
|
+
export type QuotedIssue = {
|
|
27
|
+
readonly message: string;
|
|
28
|
+
readonly params: QuotedIssueParams;
|
|
29
|
+
};
|
|
30
|
+
/** Fixed sentence text spliced into a quoted sentence (a migration signpost, a closed vocabulary listed as the way out, a field
|
|
31
|
+
* name): it joins the template, it is NOT an operand —— operands are the values the config holds, the only parts a log boundary
|
|
32
|
+
* fingerprints and the sanitizer bounds (S-815). */
|
|
33
|
+
export interface FixedText {
|
|
34
|
+
readonly fixedText: string;
|
|
35
|
+
}
|
|
36
|
+
export declare const fixed: (text: string) => FixedText;
|
|
37
|
+
/** Mint a custom issue's `message` + `params` from a tagged template (`buildQuotedIssue\`duplicate model "${name}"\``): every
|
|
38
|
+
* interpolation is an operand unless wrapped in {@link fixed} (then it is template text). THE minting point of custom issues in this
|
|
39
|
+
* package — a sentence written straight into `message` would put a value into a sentence with no record of where it is, and give
|
|
40
|
+
* readers a second way to read an issue. */
|
|
41
|
+
export declare function buildQuotedIssue(strings: TemplateStringsArray, ...parts: (string | number | FixedText)[]): QuotedIssue;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包内私有叶子模块(运行期零依赖,浏览器安全):**custom issue 的唯一铸点**(全包一只;11.0.0 时它住在 `./types` 里、只管那一个文件)。
|
|
3
|
+
*
|
|
4
|
+
* 律(一条):本包 schema 自己铸的每一条 custom issue —— `ctx.addIssue({ code: "custom", … })`、`.refine(判据, …)`、引用完整性的 `RefIssue`
|
|
5
|
+
* —— 都带 {@link QuotedIssueParams}(句子的固定词 `template` 与值 `operands`,值位是洞),`message` 恒是 {@link renderQuotedSentence} 从它渲出的,
|
|
6
|
+
* 铸点不手写第二遍。读者因此对 custom issue 只有一种读法:读结构化的句子(日志边界逐值打指纹、诊断面把值换成洞),不在「带 params 的读 params、
|
|
7
|
+
* 不带的退回 message」之间分流。没有值的定句一样走这里(`operands` 为空)。源码门在测试 custom-issue-params(扫 `src/` 全部铸点)。
|
|
8
|
+
*
|
|
9
|
+
* 不在本律里的(如实):zod 内建码的 issue —— `.max(n, 句)` / `.regex(re, 句)` 的 `too_big` / `invalid_format`、墓碑字段 `z.never` 的 `invalid_type`、
|
|
10
|
+
* 联合的错误映射 —— 它们的码不是 `custom`,`params` 不是 zod 给它们的槽位;它们的句子是定句(不插配置里的值),读者读 `message`。
|
|
11
|
+
*/
|
|
12
|
+
/** The one rendering of a quoted sentence: `template[0] + show(operands[0]) + template[1] + … + template[n]`. `show` defaults to
|
|
13
|
+
* the value as written (that is how `message` is minted); a sanitizer / log boundary passes its own. */
|
|
14
|
+
export function renderQuotedSentence(p, show = String) {
|
|
15
|
+
return p.template.reduce((out, part, i) => (i === 0 ? part : `${out}${show(p.operands[i - 1])}${part}`), "");
|
|
16
|
+
}
|
|
17
|
+
export const fixed = (text) => ({ fixedText: text });
|
|
18
|
+
/** Mint a custom issue's `message` + `params` from a tagged template (`buildQuotedIssue\`duplicate model "${name}"\``): every
|
|
19
|
+
* interpolation is an operand unless wrapped in {@link fixed} (then it is template text). THE minting point of custom issues in this
|
|
20
|
+
* package — a sentence written straight into `message` would put a value into a sentence with no record of where it is, and give
|
|
21
|
+
* readers a second way to read an issue. */
|
|
22
|
+
export function buildQuotedIssue(strings, ...parts) {
|
|
23
|
+
const template = [strings[0]];
|
|
24
|
+
const operands = [];
|
|
25
|
+
parts.forEach((part, i) => {
|
|
26
|
+
if (typeof part === "object")
|
|
27
|
+
template[template.length - 1] += part.fixedText + strings[i + 1];
|
|
28
|
+
else {
|
|
29
|
+
operands.push(part);
|
|
30
|
+
template.push(strings[i + 1]);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const params = { template, operands };
|
|
34
|
+
return { message: renderQuotedSentence(params), params };
|
|
35
|
+
}
|
package/dist/remote-exec.d.ts
CHANGED
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
* ONLY; the service owns the adapter implementations.
|
|
28
28
|
*/
|
|
29
29
|
import { z } from "zod";
|
|
30
|
+
/** The name of this contract's document in a `config.d` directory (`config.d/remote-exec.json`) —— the one spelling: the local reader /
|
|
31
|
+
* writer, the directory migration and the command line name the document by it. It is not a config domain (not in `DOMAIN_SCHEMAS`). */
|
|
32
|
+
export declare const REMOTE_EXEC_DOCUMENT: "remote-exec";
|
|
30
33
|
export declare const RemoteExecSpec: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
31
34
|
provider: z.ZodLiteral<"host">;
|
|
32
35
|
workdir: z.ZodOptional<z.ZodString>;
|
package/dist/remote-exec.js
CHANGED
|
@@ -28,11 +28,15 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { z } from "zod";
|
|
30
30
|
import { INTEGER } from "./integer.js";
|
|
31
|
-
import {
|
|
31
|
+
import { buildQuotedIssue } from "./quoted-issue.js";
|
|
32
|
+
import { ENV_NAME, K8S_QUANTITY, NON_SECRET_URL, TCP_PORT, containsSecretToken, hasNul } from "./types.js";
|
|
32
33
|
import { retiredLaneWordRefusal } from "./execution-lanes.js";
|
|
34
|
+
/** The name of this contract's document in a `config.d` directory (`config.d/remote-exec.json`) —— the one spelling: the local reader /
|
|
35
|
+
* writer, the directory migration and the command line name the document by it. It is not a config domain (not in `DOMAIN_SCHEMAS`). */
|
|
36
|
+
export const REMOTE_EXEC_DOCUMENT = "remote-exec";
|
|
33
37
|
/** A non-empty exec-bound string (an image / path / shell / arg the adapter feeds to spawn()/fs). Rejects a NUL
|
|
34
38
|
* byte uniformly — a NUL validates here but makes Node throw at spawn/boot (fail-OPEN → runtime crash). R20. */
|
|
35
|
-
const execStr = z.string().min(1).refine((s) => !hasNul(s),
|
|
39
|
+
const execStr = z.string().min(1).refine((s) => !hasNul(s), buildQuotedIssue `must not contain a NUL byte`);
|
|
36
40
|
/** Container knobs of the `local-docker` lane. `network` defaults to `none` at the adapter (the safe posture for
|
|
37
41
|
* untrusted code — see the dual-mode design note). */
|
|
38
42
|
const DOCKER_FIELDS = {
|
|
@@ -83,8 +87,9 @@ export const RemoteExecSpec = z.discriminatedUnion("provider", [
|
|
|
83
87
|
provider: z.literal("k8s"),
|
|
84
88
|
/** Sandbox container image (service: K8S_SANDBOX_IMAGE). Required so a run is reproducible. */
|
|
85
89
|
image: execStr,
|
|
86
|
-
/** Kube API URL (service: K8S_API_URL); absent → in-cluster service account. NO embedded credentials.
|
|
87
|
-
|
|
90
|
+
/** Kube API URL (service: K8S_API_URL); absent → in-cluster service account. NO embedded credentials. A stored fragment is rewritten
|
|
91
|
+
* by the one-time migration SS-M7 (this file is one of its documents), so the refusal names it. */
|
|
92
|
+
apiUrl: NON_SECRET_URL.optional(),
|
|
88
93
|
/** env-NAME holding the kube bearer token (service: K8S_TOKEN). NEVER the token. */
|
|
89
94
|
tokenEnv: ENV_NAME.optional(),
|
|
90
95
|
/** env-NAME holding the base64 CA cert (service: K8S_CA_CERT_B64). NEVER the cert bytes. */
|
|
@@ -102,7 +107,7 @@ export const RemoteExecSpec = z.discriminatedUnion("provider", [
|
|
|
102
107
|
* "all three of endpoint/access/secret required" gate). */
|
|
103
108
|
s3Snapshot: z
|
|
104
109
|
.object({
|
|
105
|
-
endpoint:
|
|
110
|
+
endpoint: NON_SECRET_URL, // S3/MinIO endpoint — creds go in accessKeyEnv/secretKeyEnv, never the URL (a stored fragment: SS-M7, like apiUrl)
|
|
106
111
|
bucket: execStr.optional(),
|
|
107
112
|
accessKeyEnv: ENV_NAME,
|
|
108
113
|
secretKeyEnv: ENV_NAME,
|
|
@@ -117,9 +122,9 @@ export const RemoteExecSpec = z.discriminatedUnion("provider", [
|
|
|
117
122
|
provider: z.literal("ssh"),
|
|
118
123
|
// host + username are persisted to config.d/remote-exec.json — scan for a secret-shaped literal (a secret must go
|
|
119
124
|
// in privateKeyEnv, not the login). R14.
|
|
120
|
-
host: execStr.refine((s) => !containsSecretToken(s),
|
|
125
|
+
host: execStr.refine((s) => !containsSecretToken(s), buildQuotedIssue `no secret literal in ssh host — credentials go in privateKeyEnv`),
|
|
121
126
|
port: TCP_PORT.optional(),
|
|
122
|
-
username: execStr.refine((s) => !containsSecretToken(s),
|
|
127
|
+
username: execStr.refine((s) => !containsSecretToken(s), buildQuotedIssue `no secret literal in ssh username — credentials go in privateKeyEnv`),
|
|
123
128
|
/** env-NAME holding the SSH private key (service: SSH_PRIVATE_KEY). NEVER the key value. */
|
|
124
129
|
privateKeyEnv: ENV_NAME,
|
|
125
130
|
mountPath: execStr.optional(),
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包内私有叶子模块:**一只** RFC 3339 时刻判(从 `./web-admin-credential` 搬出 —— 凭证信封的 `expiresAt` 与项目登记的 `archivedAt` 读同一只,
|
|
3
|
+
* 不各写一份)。
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
/** RFC 3339 `date-time` 的**剖面**:zod 的 `z.iso.datetime({offset:true})` 管日历有效性(月日范围、闰年、时分秒范围),
|
|
7
|
+
* 这里的正则自己判另三处 —— 秒必备、数字偏移必须是 `±hh:mm`、偏移的时 00–23 / 分 00–59(`10:00Z`、`+0800` 与 `+99:99`
|
|
8
|
+
* 都不是 RFC 3339,后者 `Date.parse` 读成 NaN)。这三处的判据在本正则,不随 zod 哪一版收不收而变。大写 `T` / `Z`、不收闰秒 `:60`:读方普遍用
|
|
9
|
+
* `Date.parse` 取时刻,这是那组形里它处处读得准的子集。 */
|
|
10
|
+
export declare const RFC3339_DATE_TIME: z.ZodISODateTime;
|
package/dist/rfc3339.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包内私有叶子模块:**一只** RFC 3339 时刻判(从 `./web-admin-credential` 搬出 —— 凭证信封的 `expiresAt` 与项目登记的 `archivedAt` 读同一只,
|
|
3
|
+
* 不各写一份)。
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
/** RFC 3339 `date-time` 的**剖面**:zod 的 `z.iso.datetime({offset:true})` 管日历有效性(月日范围、闰年、时分秒范围),
|
|
7
|
+
* 这里的正则自己判另三处 —— 秒必备、数字偏移必须是 `±hh:mm`、偏移的时 00–23 / 分 00–59(`10:00Z`、`+0800` 与 `+99:99`
|
|
8
|
+
* 都不是 RFC 3339,后者 `Date.parse` 读成 NaN)。这三处的判据在本正则,不随 zod 哪一版收不收而变。大写 `T` / `Z`、不收闰秒 `:60`:读方普遍用
|
|
9
|
+
* `Date.parse` 取时刻,这是那组形里它处处读得准的子集。 */
|
|
10
|
+
export const RFC3339_DATE_TIME = z.iso
|
|
11
|
+
.datetime({ offset: true })
|
|
12
|
+
.regex(/T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/, "must be an RFC 3339 date-time: seconds and a Z / ±hh:mm offset (hh 00-23, mm 00-59) are required");
|