grokbot-control 0.0.0-stage → 0.1.3

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.
Files changed (122) hide show
  1. package/README.md +160 -2
  2. package/dist/auth/lock.d.ts +14 -0
  3. package/dist/auth/lock.js +71 -0
  4. package/dist/auth/provider.d.ts +89 -0
  5. package/dist/auth/provider.js +884 -0
  6. package/dist/auth/store.d.ts +25 -0
  7. package/dist/auth/store.js +106 -0
  8. package/dist/auth/types.d.ts +138 -0
  9. package/dist/auth/types.js +1 -0
  10. package/dist/cli/config.d.ts +26 -0
  11. package/dist/cli/config.js +98 -0
  12. package/dist/cli/help.d.ts +1 -0
  13. package/dist/cli/help.js +83 -0
  14. package/dist/cli/io.d.ts +6 -0
  15. package/dist/cli/io.js +61 -0
  16. package/dist/cli/main.d.ts +2 -0
  17. package/dist/cli/main.js +275 -0
  18. package/dist/cli/sync.d.ts +4 -0
  19. package/dist/cli/sync.js +105 -0
  20. package/dist/connect/client.d.ts +32 -0
  21. package/dist/connect/client.js +257 -0
  22. package/dist/dto/result.d.ts +33 -0
  23. package/dist/dto/result.js +29 -0
  24. package/dist/dto/types.d.ts +63 -0
  25. package/dist/dto/types.js +2 -0
  26. package/dist/errors.d.ts +31 -0
  27. package/dist/errors.js +64 -0
  28. package/dist/index.d.ts +13 -0
  29. package/dist/index.js +11 -0
  30. package/dist/mcp/main.d.ts +2 -0
  31. package/dist/mcp/main.js +82 -0
  32. package/dist/mcp/tools.d.ts +24 -0
  33. package/dist/mcp/tools.js +408 -0
  34. package/dist/mcp/zod-json.d.ts +2 -0
  35. package/dist/mcp/zod-json.js +4 -0
  36. package/dist/ops/auth.d.ts +48 -0
  37. package/dist/ops/auth.js +89 -0
  38. package/dist/ops/bot.d.ts +52 -0
  39. package/dist/ops/bot.js +211 -0
  40. package/dist/ops/limits.d.ts +10 -0
  41. package/dist/ops/limits.js +31 -0
  42. package/dist/ops/map.d.ts +7 -0
  43. package/dist/ops/map.js +57 -0
  44. package/dist/ops/message.d.ts +33 -0
  45. package/dist/ops/message.js +118 -0
  46. package/dist/ops/plane.d.ts +26 -0
  47. package/dist/ops/plane.js +33 -0
  48. package/dist/ops/progress.d.ts +34 -0
  49. package/dist/ops/progress.js +92 -0
  50. package/dist/ops/session.d.ts +13 -0
  51. package/dist/ops/session.js +34 -0
  52. package/dist/ops/sidebar.d.ts +70 -0
  53. package/dist/ops/sidebar.js +205 -0
  54. package/dist/sync/attachments.d.ts +76 -0
  55. package/dist/sync/attachments.js +274 -0
  56. package/dist/sync/core.d.ts +92 -0
  57. package/dist/sync/core.js +1027 -0
  58. package/dist/sync/decode.d.ts +83 -0
  59. package/dist/sync/decode.js +229 -0
  60. package/dist/sync/download-policy.d.ts +3 -0
  61. package/dist/sync/download-policy.js +103 -0
  62. package/dist/sync/fs.d.ts +24 -0
  63. package/dist/sync/fs.js +252 -0
  64. package/dist/sync/hydrate.d.ts +43 -0
  65. package/dist/sync/hydrate.js +472 -0
  66. package/dist/sync/render.d.ts +5 -0
  67. package/dist/sync/render.js +100 -0
  68. package/dist/testing/mock-persist.d.ts +3 -0
  69. package/dist/testing/mock-persist.js +45 -0
  70. package/dist/testing/mock-transport.d.ts +18 -0
  71. package/dist/testing/mock-transport.js +202 -0
  72. package/docs/acceptance.md +19 -0
  73. package/docs/architecture.md +128 -0
  74. package/docs/auth-lock.md +87 -0
  75. package/docs/implementation-plan.md +14 -0
  76. package/docs/implementation-status.md +92 -0
  77. package/docs/proto-drift.md +9 -0
  78. package/docs/protocol/README.md +34 -0
  79. package/docs/protocol/attachment-pagination-0.79.3.md +32 -0
  80. package/docs/protocol/auth-flow.md +51 -0
  81. package/docs/protocol/baseline-manifest.md +72 -0
  82. package/docs/protocol/baselines/full-schema-diff-0.78.4-to-0.79.2.txt +239 -0
  83. package/docs/protocol/baselines/full-schema-diff-0.78.4-to-0.79.3.txt +240 -0
  84. package/docs/protocol/baselines/full-schema-diff-0.79.2-to-0.79.3.txt +23 -0
  85. package/docs/protocol/baselines/schema-0.79.3/README.md +7 -0
  86. package/docs/protocol/baselines/schema-0.79.3/agent.v1.proto +5184 -0
  87. package/docs/protocol/baselines/schema-0.79.3/aiserver.v1.proto +18182 -0
  88. package/docs/protocol/baselines/schema-0.79.3/aiserver.v1_2.proto +54 -0
  89. package/docs/protocol/baselines/schema-0.79.3/anyrun.v1.proto +37 -0
  90. package/docs/protocol/baselines/schema-0.79.3/internapi.v1.proto +35 -0
  91. package/docs/protocol/baselines/schema-0.79.3/snapshot.json.gz +0 -0
  92. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.AgentStoreService.proto +34 -0
  93. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.AiService.proto +17 -0
  94. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.AnalyticsService.proto +17 -0
  95. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.BackgroundComposerService.proto +19 -0
  96. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.DashboardService.proto +101 -0
  97. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.GrokBotService.proto +473 -0
  98. package/docs/protocol/baselines/schema-0.79.3/svc_aiserver.v1.SandBoxService.proto +64 -0
  99. package/docs/protocol/baselines/used-surface-0.78.4.json +3694 -0
  100. package/docs/protocol/baselines/used-surface-0.79.2.json +3835 -0
  101. package/docs/protocol/baselines/used-surface-0.79.3.json +3835 -0
  102. package/docs/protocol/baselines/used-surface-diff-0.78.4-to-0.79.2.md +34 -0
  103. package/docs/protocol/baselines/used-surface-diff-0.78.4-to-0.79.3.md +33 -0
  104. package/docs/protocol/compat-matrix.md +20 -0
  105. package/docs/protocol/static-restore.md +64 -0
  106. package/docs/protocol/surface-map.md +39 -0
  107. package/docs/protocol/transcript-body-0.79.3.md +98 -0
  108. package/docs/protocol/update-runbook.md +30 -0
  109. package/docs/protocol-map.md +106 -0
  110. package/docs/references.md +55 -0
  111. package/docs/requirements.md +26 -0
  112. package/docs/review.md +36 -0
  113. package/docs/sync-format.md +98 -0
  114. package/package.json +60 -4
  115. package/vendor/proto-gen/NOTICE.md +23 -0
  116. package/vendor/proto-gen/agent.v1_pb.js +9503 -0
  117. package/vendor/proto-gen/aiserver.v1_2_pb.js +105 -0
  118. package/vendor/proto-gen/aiserver.v1_pb.js +33163 -0
  119. package/vendor/proto-gen/anyrun.v1_pb.js +64 -0
  120. package/vendor/proto-gen/internapi.v1_pb.js +60 -0
  121. package/vendor/proto-gen/package.json +10 -0
  122. package/vendor/proto-gen/svc_aiserver.v1.GrokBotService_connect.js +4193 -0
@@ -0,0 +1,83 @@
1
+ /** Static 0.79.3 evidence: node-agent-coordinator Tt -> JSON.parse(h.decode(body))
2
+ * -> _b/Are discriminated UI-entry validator. See docs/protocol/transcript-body-0.79.3.md.
3
+ * A deliberately strict subset, NOT arbitrary UTF-8, protobuf guessing or decryption.
4
+ */
5
+ export declare const DECODER = "grokbot-ui-entry-json/0.79.3-subset-v1";
6
+ export declare const BODY_DECODE_MAX: number;
7
+ export declare const SUPPORTED_KINDS: readonly ["message", "send-message", "tool-call", "notice", "user-attachment"];
8
+ export type DecodedBody = {
9
+ format: typeof DECODER;
10
+ kind: (typeof SUPPORTED_KINDS)[number];
11
+ body_id: string;
12
+ role?: string;
13
+ role_source?: "body.role" | "ui-kind";
14
+ timestamp_ms?: string;
15
+ text?: string;
16
+ message_type?: "text" | "attachment";
17
+ tool_name?: string;
18
+ tool_status?: string;
19
+ attachment?: {
20
+ file_path: string;
21
+ file_name?: string;
22
+ };
23
+ suppressed?: boolean;
24
+ };
25
+ export type BodyDecode = {
26
+ decode_status: "decoded" | "opaque" | "absent" | "omitted";
27
+ decode_reason?: string;
28
+ encryption_status: "unknown" | "plaintext_json";
29
+ decoded?: DecodedBody;
30
+ body_source?: "observed_inline" | "hydrated_omitted" | "hydrated_blob";
31
+ };
32
+ export type Hydration = {
33
+ format_version: 1;
34
+ status: "fetched" | "error";
35
+ binding: string;
36
+ source: "omitted_list" | "blob";
37
+ context: {
38
+ archive_scope_sha256: string;
39
+ agent_id: string;
40
+ session_id: string;
41
+ generation: number;
42
+ seq: string;
43
+ updated_seq: string;
44
+ entry_id?: string;
45
+ };
46
+ observed_at_ms: string;
47
+ reason?: string;
48
+ body_base64?: string;
49
+ content_sha256?: string;
50
+ object_rel?: string;
51
+ remote_blob_ref?: string;
52
+ /** Attempts for this binding; deterministic failures set retry:"never". */
53
+ attempts?: number;
54
+ retry?: "never" | "retryable";
55
+ /** Meta/wire only — body bytes live in object_rel, not duplicated here. */
56
+ reread?: {
57
+ raw: unknown;
58
+ wire_base64?: string;
59
+ object_rel?: string;
60
+ content_sha256?: string;
61
+ };
62
+ };
63
+ export type ObjectReader = (rel: string) => Buffer | undefined;
64
+ type StoredInput = {
65
+ archive_scope_sha256?: string;
66
+ body_base64?: string;
67
+ body_omitted: boolean;
68
+ entry_kind: string;
69
+ agent_id?: string;
70
+ session_id?: string;
71
+ generation?: number;
72
+ seq?: string;
73
+ updated_seq?: string;
74
+ entry_id?: string;
75
+ blob_hash?: string;
76
+ hydration?: Hydration;
77
+ };
78
+ export declare function hydrationBinding(e: StoredInput): string;
79
+ export declare function verifiedHydratedBytes(e: StoredInput, readObject?: ObjectReader): Buffer | undefined;
80
+ export declare function decodeBody(body: Uint8Array | undefined, omitted: boolean, outerKind: string): BodyDecode;
81
+ /** Offline re-render also upgrades archives written before the decoder existed. */
82
+ export declare function decodeStored(e: StoredInput, readObject?: ObjectReader): BodyDecode;
83
+ export {};
@@ -0,0 +1,229 @@
1
+ import { createHash } from "node:crypto";
2
+ /** Static 0.79.3 evidence: node-agent-coordinator Tt -> JSON.parse(h.decode(body))
3
+ * -> _b/Are discriminated UI-entry validator. See docs/protocol/transcript-body-0.79.3.md.
4
+ * A deliberately strict subset, NOT arbitrary UTF-8, protobuf guessing or decryption.
5
+ */
6
+ export const DECODER = "grokbot-ui-entry-json/0.79.3-subset-v1";
7
+ export const BODY_DECODE_MAX = 4 * 1024 * 1024;
8
+ export const SUPPORTED_KINDS = [
9
+ "message",
10
+ "send-message",
11
+ "tool-call",
12
+ "notice",
13
+ "user-attachment",
14
+ ];
15
+ export function hydrationBinding(e) {
16
+ return createHash("sha256")
17
+ .update(JSON.stringify([
18
+ e.archive_scope_sha256,
19
+ e.agent_id,
20
+ e.session_id,
21
+ e.generation,
22
+ e.seq,
23
+ e.updated_seq,
24
+ e.entry_id ?? null,
25
+ e.entry_kind,
26
+ e.blob_hash ?? null,
27
+ e.body_omitted,
28
+ e.body_base64 ?? null,
29
+ ]))
30
+ .digest("hex");
31
+ }
32
+ export function verifiedHydratedBytes(e, readObject) {
33
+ const h = e.hydration;
34
+ if (!h ||
35
+ h.format_version !== 1 ||
36
+ h.status !== "fetched" ||
37
+ h.binding !== hydrationBinding(e) ||
38
+ !/^[0-9a-f]{64}$/.test(h.content_sha256 ?? ""))
39
+ return;
40
+ const c = h.context;
41
+ if (!c ||
42
+ typeof e.archive_scope_sha256 !== "string" ||
43
+ !/^[0-9a-f]{64}$/.test(e.archive_scope_sha256) ||
44
+ c.archive_scope_sha256 !== e.archive_scope_sha256 ||
45
+ c.agent_id !== e.agent_id ||
46
+ c.session_id !== e.session_id ||
47
+ c.generation !== e.generation ||
48
+ c.seq !== e.seq ||
49
+ c.updated_seq !== e.updated_seq ||
50
+ c.entry_id !== e.entry_id)
51
+ return;
52
+ if (h.source !== "omitted_list" && h.source !== "blob")
53
+ return;
54
+ if (h.object_rel !== `bodies/objects/sha256-${h.content_sha256}`)
55
+ return;
56
+ // Canonical copy is the content-addressed object; body_base64 is legacy/optional.
57
+ if (readObject) {
58
+ try {
59
+ const b = readObject(h.object_rel);
60
+ if (b &&
61
+ b.byteLength <= BODY_DECODE_MAX &&
62
+ createHash("sha256").update(b).digest("hex") === h.content_sha256)
63
+ return b;
64
+ }
65
+ catch {
66
+ return;
67
+ }
68
+ }
69
+ if (typeof h.body_base64 !== "string")
70
+ return;
71
+ if (h.body_base64.length > Math.ceil(BODY_DECODE_MAX / 3) * 4)
72
+ return;
73
+ const b = Buffer.from(h.body_base64, "base64");
74
+ if (b.byteLength > BODY_DECODE_MAX ||
75
+ b.toString("base64") !== h.body_base64 ||
76
+ createHash("sha256").update(b).digest("hex") !== h.content_sha256)
77
+ return;
78
+ return b;
79
+ }
80
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
81
+ const optionalString = (v) => v === undefined || typeof v === "string";
82
+ function opaque(reason) {
83
+ return {
84
+ decode_status: "opaque",
85
+ encryption_status: "unknown",
86
+ decode_reason: reason,
87
+ };
88
+ }
89
+ export function decodeBody(body, omitted, outerKind) {
90
+ if (omitted)
91
+ return { decode_status: "omitted", encryption_status: "unknown" };
92
+ if (body === undefined)
93
+ return { decode_status: "absent", encryption_status: "unknown" };
94
+ if (body.byteLength > BODY_DECODE_MAX)
95
+ return opaque("decoder_size_limit");
96
+ let v;
97
+ try {
98
+ v = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
99
+ }
100
+ catch {
101
+ return opaque("not_valid_utf8_json");
102
+ }
103
+ if (!object(v) ||
104
+ typeof v.id !== "string" ||
105
+ !v.id.length ||
106
+ v.id.length > 1024 ||
107
+ typeof v.kind !== "string")
108
+ return opaque("invalid_ui_entry_schema");
109
+ if (!SUPPORTED_KINDS.includes(v.kind))
110
+ return opaque("unsupported_ui_kind");
111
+ // UI uses the JSON discriminator; mismatching nonempty outer metadata is conservatively refused.
112
+ if (outerKind && outerKind !== v.kind)
113
+ return opaque("outer_inner_kind_mismatch");
114
+ const d = {
115
+ format: DECODER,
116
+ kind: v.kind,
117
+ body_id: v.id,
118
+ };
119
+ const t = v.timestampMs;
120
+ if (typeof t === "number" &&
121
+ Number.isSafeInteger(t) &&
122
+ t >= 0 &&
123
+ t <= 8640000000000000)
124
+ d.timestamp_ms = String(t);
125
+ switch (d.kind) {
126
+ case "message":
127
+ if (typeof v.content !== "string" || !optionalString(v.role))
128
+ return opaque("invalid_ui_entry_schema");
129
+ d.text = v.content;
130
+ if (typeof v.role === "string") {
131
+ d.role = v.role;
132
+ d.role_source = "body.role";
133
+ }
134
+ if (v.suppressed !== undefined && v.suppressed !== null)
135
+ d.suppressed = true;
136
+ break;
137
+ case "send-message":
138
+ if (!object(v.message))
139
+ return opaque("invalid_ui_entry_schema");
140
+ if (v.message.type === "attachment") {
141
+ if (typeof v.message.url !== "string" ||
142
+ !optionalString(v.message.file_name))
143
+ return opaque("invalid_ui_entry_schema");
144
+ d.message_type = "attachment";
145
+ d.attachment = {
146
+ file_path: v.message.url,
147
+ ...(typeof v.message.file_name === "string"
148
+ ? { file_name: v.message.file_name }
149
+ : {}),
150
+ };
151
+ d.role = "assistant";
152
+ d.role_source = "ui-kind";
153
+ break;
154
+ }
155
+ if (v.message.type !== "text")
156
+ return opaque("unsupported_send_message_type");
157
+ if (typeof v.message.content !== "string")
158
+ return opaque("invalid_ui_entry_schema");
159
+ d.message_type = "text";
160
+ d.text = v.message.content;
161
+ d.role = "assistant";
162
+ d.role_source = "ui-kind";
163
+ break;
164
+ case "tool-call":
165
+ if (typeof v.name !== "string" ||
166
+ !(v.summary === null || optionalString(v.summary)) ||
167
+ !(v.status === null || optionalString(v.status)))
168
+ return opaque("invalid_ui_entry_schema");
169
+ d.tool_name = v.name;
170
+ if (typeof v.summary === "string")
171
+ d.text = v.summary;
172
+ if (typeof v.status === "string")
173
+ d.tool_status = v.status;
174
+ break;
175
+ case "notice":
176
+ if (typeof v.text !== "string")
177
+ return opaque("invalid_ui_entry_schema");
178
+ d.text = v.text;
179
+ break;
180
+ case "user-attachment":
181
+ if (typeof v.file_path !== "string" || !optionalString(v.file_name))
182
+ return opaque("invalid_ui_entry_schema");
183
+ d.attachment = {
184
+ file_path: v.file_path,
185
+ ...(typeof v.file_name === "string" ? { file_name: v.file_name } : {}),
186
+ };
187
+ d.role = "user";
188
+ d.role_source = "ui-kind";
189
+ break;
190
+ }
191
+ return {
192
+ decode_status: "decoded",
193
+ encryption_status: "plaintext_json",
194
+ decoded: d,
195
+ };
196
+ }
197
+ /** Offline re-render also upgrades archives written before the decoder existed. */
198
+ export function decodeStored(e, readObject) {
199
+ // Inline observation always wins, even if cached hydration was supplied.
200
+ if (e.body_base64 === undefined) {
201
+ const recovered = verifiedHydratedBytes(e, readObject);
202
+ if (recovered)
203
+ return {
204
+ ...decodeBody(recovered, false, e.entry_kind),
205
+ body_source: e.hydration.source === "blob" ? "hydrated_blob" : "hydrated_omitted",
206
+ };
207
+ if (e.hydration &&
208
+ (e.hydration.status === "fetched" || e.hydration.format_version !== 1))
209
+ return opaque("invalid_stored_hydration");
210
+ }
211
+ if (e.body_omitted && e.body_base64 === undefined)
212
+ return decodeBody(undefined, true, e.entry_kind);
213
+ if (e.body_base64 === undefined)
214
+ return decodeBody(undefined, false, e.entry_kind);
215
+ if (typeof e.body_base64 !== "string")
216
+ return opaque("invalid_stored_base64");
217
+ if (e.body_base64.length > Math.ceil(BODY_DECODE_MAX / 3) * 4)
218
+ return opaque("decoder_size_limit");
219
+ if (e.body_base64.length % 4 !== 0 ||
220
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(e.body_base64))
221
+ return opaque("invalid_stored_base64");
222
+ const bytes = Buffer.from(e.body_base64, "base64");
223
+ if (bytes.toString("base64") !== e.body_base64)
224
+ return opaque("invalid_stored_base64");
225
+ return {
226
+ ...decodeBody(bytes, false, e.entry_kind),
227
+ body_source: "observed_inline",
228
+ };
229
+ }
@@ -0,0 +1,3 @@
1
+ import type { FetchFn } from "./attachments.js";
2
+ export declare function publicAddress(ip: string): boolean;
3
+ export declare const publicHttpsFetch: FetchFn;
@@ -0,0 +1,103 @@
1
+ /** Public HTTPS only. Resolve once, reject reserved addresses, pin request DNS result.
2
+ * No bearer/cookies, no redirects, standard TLS hostname/certificate validation.
3
+ * Injected fetch functions (tests) are trusted SDK code, not a CLI bypass.
4
+ */
5
+ import https from "node:https";
6
+ import dns from "node:dns/promises";
7
+ import { BlockList, isIP } from "node:net";
8
+ import { Readable } from "node:stream";
9
+ const deny = new BlockList(), deny6 = new BlockList(), global6 = new BlockList();
10
+ global6.addSubnet("2000::", 3, "ipv6");
11
+ for (const [ip, bits] of [
12
+ ["0.0.0.0", 8],
13
+ ["10.0.0.0", 8],
14
+ ["100.64.0.0", 10],
15
+ ["127.0.0.0", 8],
16
+ ["169.254.0.0", 16],
17
+ ["172.16.0.0", 12],
18
+ ["192.0.0.0", 24],
19
+ ["192.0.2.0", 24],
20
+ ["192.168.0.0", 16],
21
+ ["192.88.99.0", 24],
22
+ ["198.18.0.0", 15],
23
+ ["198.51.100.0", 24],
24
+ ["203.0.113.0", 24],
25
+ ["224.0.0.0", 4],
26
+ ["240.0.0.0", 4],
27
+ ])
28
+ deny.addSubnet(ip, bits, "ipv4");
29
+ for (const [ip, bits] of [
30
+ ["::", 96],
31
+ ["::ffff:0:0", 96],
32
+ ["fc00::", 7],
33
+ ["fe80::", 10],
34
+ ["ff00::", 8],
35
+ ["2001:db8::", 32],
36
+ ["2001::", 23],
37
+ ["2002::", 16],
38
+ ])
39
+ deny6.addSubnet(ip, bits, "ipv6");
40
+ export function publicAddress(ip) {
41
+ const family = isIP(ip);
42
+ return (!!family &&
43
+ (family !== 6 || global6.check(ip, "ipv6")) &&
44
+ !(family === 4 ? deny : deny6).check(ip, family === 4 ? "ipv4" : "ipv6"));
45
+ }
46
+ export const publicHttpsFetch = async (value, init) => {
47
+ const u = new URL(value);
48
+ if (u.protocol !== "https:" ||
49
+ u.username ||
50
+ u.password ||
51
+ u.hash ||
52
+ (u.port && u.port !== "443"))
53
+ throw new Error("download policy");
54
+ const host = u.hostname.replace(/^\[|\]$/g, "");
55
+ if (init?.signal?.aborted)
56
+ throw new Error("aborted");
57
+ let listener;
58
+ let choices;
59
+ try {
60
+ choices = isIP(host)
61
+ ? [{ address: host, family: isIP(host) }]
62
+ : await Promise.race([
63
+ dns.lookup(host, { all: true }),
64
+ new Promise((_, reject) => {
65
+ listener = () => reject(new Error("aborted"));
66
+ init?.signal?.addEventListener("abort", listener, { once: true });
67
+ }),
68
+ ]);
69
+ }
70
+ finally {
71
+ if (listener)
72
+ init?.signal?.removeEventListener("abort", listener);
73
+ }
74
+ if (!choices.length || choices.some((c) => !publicAddress(c.address)))
75
+ throw new Error("download policy");
76
+ const chosen = choices[0];
77
+ return await new Promise((resolve, reject) => {
78
+ const headers = init?.headers;
79
+ const req = https.request(u, {
80
+ method: "GET",
81
+ signal: init?.signal ?? undefined,
82
+ headers,
83
+ lookup: ((_name, opts, cb) => opts?.all
84
+ ? cb(null, [chosen])
85
+ : cb(null, chosen.address, chosen.family)),
86
+ }, (res) => {
87
+ const status = res.statusCode ?? 500;
88
+ const h = new Headers();
89
+ for (const [k, v] of Object.entries(res.headers))
90
+ if (v !== undefined)
91
+ h.set(k, Array.isArray(v) ? v.join(",") : v);
92
+ const empty = status === 204 || status === 304;
93
+ if (empty)
94
+ res.resume();
95
+ resolve(new Response(empty ? null : Readable.toWeb(res), {
96
+ status,
97
+ headers: h,
98
+ }));
99
+ });
100
+ req.on("error", reject);
101
+ req.end();
102
+ });
103
+ };
@@ -0,0 +1,24 @@
1
+ export declare function denied(message: string): never;
2
+ export declare function hash(s: string): string;
3
+ export declare function component(id: string): string;
4
+ /** Default (unlisted) session has empty session_id on the wire (App omits it). */
5
+ export declare function sessionComponent(id: string): string;
6
+ export declare class Archive {
7
+ readonly root: string;
8
+ constructor(root: string, create?: boolean);
9
+ private directory;
10
+ file(rel: string, parents?: boolean): string;
11
+ read(rel: string, maxBytes?: number): string | undefined;
12
+ json(rel: string): any;
13
+ atomic(rel: string, text: string): void;
14
+ writeJson(rel: string, data: unknown): void;
15
+ syncDir(dir: string): void;
16
+ lock(): () => void;
17
+ stage(): {
18
+ write: (bytes: Uint8Array) => void;
19
+ commit: (target: string) => void;
20
+ discard: () => void;
21
+ };
22
+ /** Binary counterpart of atomic(); never follows symlinks. */
23
+ atomicBytes(rel: string, data: Uint8Array): void;
24
+ }
@@ -0,0 +1,252 @@
1
+ /** Private, single-user archive. Never follows symlinks (including ancestors).
2
+ * Not a security boundary against another process running as the same OS user.
3
+ */
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { randomUUID, createHash } from "node:crypto";
7
+ import { GbcError } from "../errors.js";
8
+ export function denied(message) {
9
+ throw new GbcError("denied", message);
10
+ }
11
+ export function hash(s) {
12
+ return createHash("sha256").update(s).digest("hex");
13
+ }
14
+ export function component(id) {
15
+ if (!id || Buffer.byteLength(id) > 120 || /[\x00-\x1f]/.test(id))
16
+ throw new GbcError("invalid_argument", "ID must be nonempty, <=120 bytes, without control characters");
17
+ return "id-" + Buffer.from(id).toString("base64url");
18
+ }
19
+ /** Default (unlisted) session has empty session_id on the wire (App omits it). */
20
+ export function sessionComponent(id) {
21
+ return id === "" ? "default" : component(id);
22
+ }
23
+ export class Archive {
24
+ root;
25
+ constructor(root, create = false) {
26
+ if (!root || !path.isAbsolute(root))
27
+ throw new GbcError("invalid_argument", "sync root must be an absolute path");
28
+ this.root = path.resolve(root);
29
+ if (this.root === path.parse(this.root).root)
30
+ denied("filesystem root is not an archive");
31
+ let cur = path.parse(this.root).root;
32
+ for (const part of this.root.slice(cur.length).split(path.sep)) {
33
+ cur = path.join(cur, part);
34
+ if (!fs.existsSync(cur)) {
35
+ // lstat also catches dangling symlinks, unlike existsSync.
36
+ try {
37
+ fs.lstatSync(cur);
38
+ denied("symlink in sync root");
39
+ }
40
+ catch (e) {
41
+ if (e.code !== "ENOENT")
42
+ throw e;
43
+ }
44
+ if (!create)
45
+ throw new GbcError("not_found", "archive does not exist");
46
+ fs.mkdirSync(cur, { mode: 0o700 });
47
+ this.syncDir(path.dirname(cur));
48
+ }
49
+ const s = fs.lstatSync(cur);
50
+ if (!s.isDirectory() || s.isSymbolicLink())
51
+ denied("sync root ancestor must be a real directory");
52
+ if ((process.getuid && s.uid !== process.getuid() && s.uid !== 0) ||
53
+ (s.mode & 0o022 && !(s.mode & 0o1000)))
54
+ denied("sync root ancestors must be trusted and not group/world writable (sticky temporary directories excepted)");
55
+ }
56
+ this.directory(this.root);
57
+ }
58
+ directory(p) {
59
+ const s = fs.lstatSync(p);
60
+ if (!s.isDirectory() ||
61
+ s.isSymbolicLink() ||
62
+ (process.getuid && s.uid !== process.getuid()) ||
63
+ s.mode & 0o077)
64
+ denied("archive directories must be owned by current user and mode 0700");
65
+ }
66
+ file(rel, parents = false) {
67
+ if (!rel ||
68
+ path.isAbsolute(rel) ||
69
+ rel.split(/[\\/]/).some((p) => !p || p === "." || p === ".."))
70
+ denied("unsafe archive path");
71
+ this.directory(this.root);
72
+ const parts = rel.split("/");
73
+ let cur = this.root;
74
+ for (const part of parts.slice(0, -1)) {
75
+ cur = path.join(cur, part);
76
+ try {
77
+ this.directory(cur);
78
+ }
79
+ catch (e) {
80
+ if (e.code !== "ENOENT" || !parents)
81
+ throw e;
82
+ fs.mkdirSync(cur, { mode: 0o700 });
83
+ this.syncDir(path.dirname(cur));
84
+ this.directory(cur);
85
+ }
86
+ }
87
+ const p = path.join(cur, parts.at(-1));
88
+ try {
89
+ const s = fs.lstatSync(p);
90
+ if (!s.isFile() ||
91
+ s.isSymbolicLink() ||
92
+ s.nlink !== 1 ||
93
+ (process.getuid && s.uid !== process.getuid()) ||
94
+ s.mode & 0o077)
95
+ denied("archive file must be private, regular and not linked");
96
+ }
97
+ catch (e) {
98
+ if (e.code !== "ENOENT")
99
+ throw e;
100
+ }
101
+ return p;
102
+ }
103
+ read(rel, maxBytes = 64 * 1024 * 1024) {
104
+ let fd;
105
+ try {
106
+ fd = fs.openSync(this.file(rel), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
107
+ const s = fs.fstatSync(fd);
108
+ if (!s.isFile() || s.size > maxBytes)
109
+ denied("archive file exceeds read bound or is not regular");
110
+ return fs.readFileSync(fd, "utf8");
111
+ }
112
+ catch (e) {
113
+ if (e.code === "ENOENT")
114
+ return undefined;
115
+ throw e;
116
+ }
117
+ finally {
118
+ if (fd !== undefined)
119
+ fs.closeSync(fd);
120
+ }
121
+ }
122
+ json(rel) {
123
+ const s = this.read(rel);
124
+ return s === undefined ? undefined : JSON.parse(s);
125
+ }
126
+ atomic(rel, text) {
127
+ const target = this.file(rel, true), tempRel = ".sync/staging/" + randomUUID() + ".tmp";
128
+ const tmp = this.file(tempRel, true);
129
+ const fd = fs.openSync(tmp, fs.constants.O_WRONLY |
130
+ fs.constants.O_CREAT |
131
+ fs.constants.O_EXCL |
132
+ fs.constants.O_NOFOLLOW, 0o600);
133
+ try {
134
+ fs.writeFileSync(fd, text);
135
+ fs.fsyncSync(fd);
136
+ }
137
+ finally {
138
+ fs.closeSync(fd);
139
+ }
140
+ // Re-check destination and ancestors immediately before rename.
141
+ this.file(rel);
142
+ fs.renameSync(tmp, target);
143
+ this.syncDir(path.dirname(target));
144
+ this.syncDir(path.dirname(tmp));
145
+ }
146
+ writeJson(rel, data) {
147
+ this.atomic(rel, JSON.stringify(data, null, 2) + "\n");
148
+ }
149
+ syncDir(dir) {
150
+ const fd = fs.openSync(dir, fs.constants.O_RDONLY |
151
+ fs.constants.O_DIRECTORY |
152
+ fs.constants.O_NOFOLLOW);
153
+ try {
154
+ fs.fsyncSync(fd);
155
+ }
156
+ finally {
157
+ fs.closeSync(fd);
158
+ }
159
+ }
160
+ lock() {
161
+ const p = this.file(".sync/lock", true);
162
+ let fd;
163
+ try {
164
+ fd = fs.openSync(p, fs.constants.O_WRONLY |
165
+ fs.constants.O_CREAT |
166
+ fs.constants.O_EXCL |
167
+ fs.constants.O_NOFOLLOW, 0o600);
168
+ }
169
+ catch (e) {
170
+ if (e.code === "EEXIST")
171
+ throw new GbcError("conflict", "archive locked; never auto-remove a stale lock; verify no writer then remove manually");
172
+ throw e;
173
+ }
174
+ const token = randomUUID();
175
+ try {
176
+ fs.writeFileSync(fd, JSON.stringify({ token, pid: process.pid }));
177
+ fs.fsyncSync(fd);
178
+ }
179
+ catch (e) {
180
+ fs.closeSync(fd);
181
+ throw e;
182
+ }
183
+ fs.closeSync(fd);
184
+ this.syncDir(path.dirname(p));
185
+ return () => {
186
+ if (this.json(".sync/lock")?.token !== token)
187
+ denied("lock ownership changed; refusing release");
188
+ fs.unlinkSync(this.file(".sync/lock"));
189
+ this.syncDir(path.dirname(p));
190
+ };
191
+ }
192
+ stage() {
193
+ const rel = ".sync/staging/" + randomUUID() + ".tmp";
194
+ const tmp = this.file(rel, true);
195
+ const fd = fs.openSync(tmp, fs.constants.O_WRONLY |
196
+ fs.constants.O_CREAT |
197
+ fs.constants.O_EXCL |
198
+ fs.constants.O_NOFOLLOW, 0o600);
199
+ let open = true, exists = true;
200
+ const close = () => {
201
+ if (open) {
202
+ fs.closeSync(fd);
203
+ open = false;
204
+ }
205
+ };
206
+ return {
207
+ write: (bytes) => {
208
+ let off = 0;
209
+ while (off < bytes.length)
210
+ off += fs.writeSync(fd, bytes, off, bytes.length - off);
211
+ },
212
+ commit: (target) => {
213
+ const dest = this.file(target, true);
214
+ fs.fsyncSync(fd);
215
+ close();
216
+ this.file(target);
217
+ fs.renameSync(tmp, dest);
218
+ exists = false;
219
+ this.syncDir(path.dirname(dest));
220
+ this.syncDir(path.dirname(tmp));
221
+ },
222
+ discard: () => {
223
+ close();
224
+ if (exists) {
225
+ fs.unlinkSync(tmp);
226
+ exists = false;
227
+ this.syncDir(path.dirname(tmp));
228
+ }
229
+ },
230
+ };
231
+ }
232
+ /** Binary counterpart of atomic(); never follows symlinks. */
233
+ atomicBytes(rel, data) {
234
+ const target = this.file(rel, true), tempRel = ".sync/staging/" + randomUUID() + ".tmp";
235
+ const tmp = this.file(tempRel, true);
236
+ const fd = fs.openSync(tmp, fs.constants.O_WRONLY |
237
+ fs.constants.O_CREAT |
238
+ fs.constants.O_EXCL |
239
+ fs.constants.O_NOFOLLOW, 0o600);
240
+ try {
241
+ fs.writeFileSync(fd, data);
242
+ fs.fsyncSync(fd);
243
+ }
244
+ finally {
245
+ fs.closeSync(fd);
246
+ }
247
+ this.file(rel);
248
+ fs.renameSync(tmp, target);
249
+ this.syncDir(path.dirname(target));
250
+ this.syncDir(path.dirname(tmp));
251
+ }
252
+ }