@sibylline-labs/eris 0.0.0-stage → 0.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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +146 -2
- package/bin/eris.ts +42 -0
- package/docs/ARCHITECTURE.md +188 -0
- package/docs/COMPLETION.md +106 -0
- package/docs/ERRORS.md +632 -0
- package/docs/OUTPUT-CONTRACT.md +219 -0
- package/docs/README.md +30 -0
- package/docs/UPDATE.md +159 -0
- package/docs/YAK.md +395 -0
- package/docs/dev/ADDING-A-COMMAND.md +79 -0
- package/docs/dev/DEVELOPMENT.md +88 -0
- package/docs/dev/LAYOUT.md +74 -0
- package/docs/dev/PLUGINS.md +382 -0
- package/docs/dev/RELEASING.md +157 -0
- package/docs/dev/TESTING.md +84 -0
- package/docs/dev/surfaces/secrets.md +95 -0
- package/package.json +55 -4
- package/packages/yak-contract/package.json +28 -0
- package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
- package/packages/yak-contract/src/index.ts +1 -0
- package/packages/yak-contract/src/v2.ts +375 -0
- package/plugins/index.ts +12 -0
- package/plugins/yak/handlers.ts +128 -0
- package/plugins/yak/index.ts +51 -0
- package/plugins/yak/login.ts +300 -0
- package/plugins/yak/yak-context.ts +280 -0
- package/plugins/yak/yak-identity.ts +399 -0
- package/plugins/yak/yak-redact.ts +686 -0
- package/plugins/yak/yak-setup.ts +133 -0
- package/plugins/yak/yak-submit.ts +1166 -0
- package/plugins/yak/yak.ts +485 -0
- package/src/build.ts +248 -0
- package/src/cli/args.ts +434 -0
- package/src/cli/commands/completion/bash.ts +24 -0
- package/src/cli/commands/completion/domain.ts +203 -0
- package/src/cli/commands/completion/emit.ts +246 -0
- package/src/cli/commands/completion/shell.ts +24 -0
- package/src/cli/commands/completion/zsh.ts +24 -0
- package/src/cli/commands/completion.ts +48 -0
- package/src/cli/commands/update/github.ts +328 -0
- package/src/cli/commands/update/opt-in.ts +32 -0
- package/src/cli/commands/update/refusal.ts +21 -0
- package/src/cli/commands/update/release.ts +351 -0
- package/src/cli/commands/update/replace.ts +143 -0
- package/src/cli/commands/update/semver.ts +48 -0
- package/src/cli/commands/update.ts +47 -0
- package/src/cli/commands/usage.ts +68 -0
- package/src/cli/commands/version.ts +29 -0
- package/src/cli/compose.ts +273 -0
- package/src/cli/output.ts +81 -0
- package/src/cli/plugin.ts +85 -0
- package/src/cli/registry.ts +180 -0
- package/src/cli/router.ts +31 -0
- package/src/cli/ts-verb.ts +126 -0
- package/src/main.ts +223 -0
- package/src/runtime/env.ts +16 -0
- package/src/runtime/eris-assets.d.ts +5 -0
- package/src/runtime/home.ts +306 -0
- package/src/runtime/logging.ts +43 -0
- package/src/runtime/process.ts +388 -0
- package/src/runtime/settings.ts +191 -0
- package/src/sdk.ts +62 -0
- package/testing/build-child.ts +55 -0
- package/testing/fake-runner.ts +23 -0
- package/testing/index.ts +16 -0
- package/ts/lib/eris-fail.ts +180 -0
- package/ts/lib/pool.ts +63 -0
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* yak-submit.ts — input resolution + the submission core for `eris yak`.
|
|
3
|
+
*
|
|
4
|
+
* Two independently-testable halves:
|
|
5
|
+
*
|
|
6
|
+
* `resolveTextInputs` — turns the parsed `--summary`/`--body`/
|
|
7
|
+
* `--body-file`/`--evidence`/`--evidence-file` flags into three raw
|
|
8
|
+
* strings, reading file/stdin BYTES DIRECTLY (never through a bash
|
|
9
|
+
* variable — the `$(cmd)`-strips-trailing-newlines trap would silently
|
|
10
|
+
* change the idempotency key derived from those bytes). Catches every
|
|
11
|
+
* "agent misuse" input shape: missing required fields, mutually-exclusive
|
|
12
|
+
* sources, an unreadable file, and `--body-file -` with no pipe, which
|
|
13
|
+
* must fail immediately rather than hang forever on a read that will
|
|
14
|
+
* never return.
|
|
15
|
+
*
|
|
16
|
+
* `submitYak` — the pipeline in this EXACT order, which is load-bearing,
|
|
17
|
+
* not incidental:
|
|
18
|
+
*
|
|
19
|
+
* 1. (caller) resolve inputs — done by `resolveTextInputs`, above
|
|
20
|
+
* 2. redact summary/body/evidence/provenance (BEFORE key
|
|
21
|
+
* derivation, so the Actor's re-derived key agrees with ours —
|
|
22
|
+
* deriving from unredacted text guarantees a mismatch on every
|
|
23
|
+
* report)
|
|
24
|
+
* 3. derive the idempotency key from the REDACTED payload
|
|
25
|
+
* 4. omit `evidence` entirely when the (redacted) bytes are empty —
|
|
26
|
+
* `evidence: ""` and `evidence` absent derive DIFFERENT keys
|
|
27
|
+
* under `JSON.stringify` (frozen as a key vector in
|
|
28
|
+
* `tests/yak-contract-v2.test.ts`, and held at this seam by the
|
|
29
|
+
* "evidence '' ... SAME key" test in `tests/yak-submit.test.ts`),
|
|
30
|
+
* so treating an empty upload the same as no upload at all is what
|
|
31
|
+
* keeps two submissions of "no evidence" deduping against each
|
|
32
|
+
* other
|
|
33
|
+
* 5. validate locally against the contract package's zod schema BEFORE the
|
|
34
|
+
* request (an over-bound field is a local, actionable
|
|
35
|
+
* exit-1 error naming the field/length/max — never a silent
|
|
36
|
+
* server-side truncation, which would desync the stored payload
|
|
37
|
+
* from the key we already derived from it)
|
|
38
|
+
* 6. one bounded `fetch` POST (30s), no retry — a retry loop is
|
|
39
|
+
* latency an agent pays mid-task
|
|
40
|
+
* 7. compare our derived key against the receipt's `idempotency_key`
|
|
41
|
+
* and flag a mismatch — the ONLY drift signal available, since the
|
|
42
|
+
* contract has no mismatch field on the receipt itself
|
|
43
|
+
*
|
|
44
|
+
* Every failure this module can produce is typed and NEVER throws or
|
|
45
|
+
* exits — same `{ value } | { reason, slug }` discriminant
|
|
46
|
+
* `plugins/yak/yak-identity.ts` already established
|
|
47
|
+
* (`IdentityOutcome`/`IdentityOk`/`IdentityFailure`, reused here rather
|
|
48
|
+
* than re-invented), plus a third `SubmitResult` branch (`"delivered"`)
|
|
49
|
+
* for the one case that isn't a failure at all. Mapping a result to an
|
|
50
|
+
* exit code, stdout/stderr, and `--output json`'s bare record is
|
|
51
|
+
* `yak.ts`'s job (the agent-misuse-vs-environment split) — this
|
|
52
|
+
* module only classifies.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { z, type ZodIssue } from "zod";
|
|
56
|
+
import {
|
|
57
|
+
YakSubmission,
|
|
58
|
+
YakReceipt,
|
|
59
|
+
YAKS_CONTRACT_VERSION,
|
|
60
|
+
YAK_REPO_HINT_MAX_LENGTH,
|
|
61
|
+
YAK_REPO_HINTS_MAX_COUNT,
|
|
62
|
+
deriveIdempotencyKey,
|
|
63
|
+
type YakVerb,
|
|
64
|
+
type YakProvenance,
|
|
65
|
+
} from "eris/yak-contract";
|
|
66
|
+
import { redact, redactDeep, type RedactionClass } from "./yak-redact";
|
|
67
|
+
import type { IdentityOutcome } from "./yak-identity";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A permissive twin of the contract package's `YakReceipt`
|
|
71
|
+
* (`@sibylline-labs/yak-contract`, `packages/yak-contract/src/v2.ts`) that
|
|
72
|
+
* accepts ANY `contract_version` string, not only the exact literal this CLI
|
|
73
|
+
* currently emits.
|
|
74
|
+
*
|
|
75
|
+
* `YakReceipt.contract_version` is
|
|
76
|
+
* `z.literal(YAKS_CONTRACT_VERSION)`. The REQUEST side already avoids this
|
|
77
|
+
* trap — `ACCEPTED_YAKS_VERSIONS` is a tuple specifically so a version bump
|
|
78
|
+
* is additive, per that file's own comment. The RESPONSE side has
|
|
79
|
+
* no such tuple, because a receipt "carries the one version that produced
|
|
80
|
+
* it" (same file) — that's a true statement about the SERVER's write, but
|
|
81
|
+
* this CLIENT re-parses that same literal on READ. The moment an Actor ships
|
|
82
|
+
* 2.1.0, every successful, DURABLY-STORED delivery would fail
|
|
83
|
+
* `YakReceipt.safeParse` here and report `delivered: false` — turning a
|
|
84
|
+
* contract_version bump into a false failure for every observation already
|
|
85
|
+
* accepted server-side. Leniency belongs on the reader, not the contract:
|
|
86
|
+
* this widens only the one field a version bump changes, and
|
|
87
|
+
* changes nothing else `YakReceipt` already validates (`id`,
|
|
88
|
+
* `idempotency_key`, `replay` all stay exactly as strict).
|
|
89
|
+
*/
|
|
90
|
+
const LenientYakReceipt = YakReceipt.extend({ contract_version: z.string() });
|
|
91
|
+
|
|
92
|
+
/** The shape `submitYak` actually returns on delivery — `YakReceipt` with
|
|
93
|
+
* `contract_version` widened to `string` (see `LenientYakReceipt` above).
|
|
94
|
+
* Structurally compatible with the package's `YakReceipt` everywhere a
|
|
95
|
+
* `string` is expected, which is everywhere this repo reads the field. */
|
|
96
|
+
export type YakReceiptResponse = z.infer<typeof LenientYakReceipt>;
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Input resolution
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
export type InputMisuseSlug =
|
|
103
|
+
| "yak-summary-required"
|
|
104
|
+
| "yak-body-required"
|
|
105
|
+
| "yak-body-conflicting-source"
|
|
106
|
+
| "yak-evidence-conflicting-source"
|
|
107
|
+
| "yak-body-file-unreadable"
|
|
108
|
+
| "yak-evidence-file-unreadable"
|
|
109
|
+
| "yak-stdin-is-a-tty"
|
|
110
|
+
| "yak-stdin-conflict";
|
|
111
|
+
|
|
112
|
+
export interface RawTextInputs {
|
|
113
|
+
summary?: string;
|
|
114
|
+
bodyInline?: string;
|
|
115
|
+
bodyFile?: string;
|
|
116
|
+
evidenceInline?: string;
|
|
117
|
+
evidenceFile?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface ResolvedTextInputs {
|
|
121
|
+
summary: string;
|
|
122
|
+
body: string;
|
|
123
|
+
/** Absent when no `--evidence`/`--evidence-file` was given AT ALL. An
|
|
124
|
+
* `--evidence-file` that resolves to zero bytes is normalized to
|
|
125
|
+
* absent too, one level up in `submitYak` (step 4, post-redaction) —
|
|
126
|
+
* not here, because redaction never turns empty into non-empty or vice
|
|
127
|
+
* versa (a class marker only ever replaces a MATCH), so doing the
|
|
128
|
+
* empty-check on raw vs. redacted bytes is equivalent; the actual
|
|
129
|
+
* normalization runs after redaction. */
|
|
130
|
+
evidence?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface TextInputDeps {
|
|
134
|
+
/** Reads a file's bytes as UTF-8 text, unmodified — no trim, no
|
|
135
|
+
* newline normalization. Default: `Bun.file(path).text()`. */
|
|
136
|
+
readFile: (path: string) => Promise<string>;
|
|
137
|
+
/** Reads all of stdin as UTF-8 text, unmodified. Default: drains
|
|
138
|
+
* `process.stdin` via async iteration. It does not `.trim()` —
|
|
139
|
+
* trimming would change the bytes the idempotency key is derived from. */
|
|
140
|
+
readStdin: () => Promise<string>;
|
|
141
|
+
/** Whether stdin is a terminal — checked before ever attempting to
|
|
142
|
+
* read it for `--body-file -`/`--evidence-file -`, so a forgotten pipe
|
|
143
|
+
* fails immediately instead of hanging. Default:
|
|
144
|
+
* `Boolean(process.stdin.isTTY)`. */
|
|
145
|
+
isStdinTTY: () => boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function defaultReadFile(path: string): Promise<string> {
|
|
149
|
+
return Bun.file(path).text();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function defaultReadStdin(): Promise<string> {
|
|
153
|
+
const chunks: Buffer[] = [];
|
|
154
|
+
for await (const chunk of process.stdin) {
|
|
155
|
+
chunks.push(chunk as Buffer);
|
|
156
|
+
}
|
|
157
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function defaultIsStdinTTY(): boolean {
|
|
161
|
+
return Boolean(process.stdin.isTTY);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function defaultTextInputDeps(overrides: Partial<TextInputDeps> = {}): TextInputDeps {
|
|
165
|
+
return {
|
|
166
|
+
readFile: overrides.readFile ?? defaultReadFile,
|
|
167
|
+
readStdin: overrides.readStdin ?? defaultReadStdin,
|
|
168
|
+
isStdinTTY: overrides.isStdinTTY ?? defaultIsStdinTTY,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function resolveOneField(
|
|
173
|
+
label: "body" | "evidence",
|
|
174
|
+
inline: string | undefined,
|
|
175
|
+
filePath: string | undefined,
|
|
176
|
+
deps: TextInputDeps,
|
|
177
|
+
): Promise<IdentityOutcome<string | undefined, InputMisuseSlug>> {
|
|
178
|
+
if (inline !== undefined && filePath !== undefined) {
|
|
179
|
+
return {
|
|
180
|
+
reason: `--${label} and --${label}-file are mutually exclusive — pass exactly one`,
|
|
181
|
+
slug: `yak-${label}-conflicting-source` as InputMisuseSlug,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (inline !== undefined) return { value: inline };
|
|
185
|
+
if (filePath === undefined) return { value: undefined };
|
|
186
|
+
|
|
187
|
+
if (filePath === "-") {
|
|
188
|
+
if (deps.isStdinTTY()) {
|
|
189
|
+
return {
|
|
190
|
+
reason: `--${label}-file - reads stdin, but stdin is a terminal — pipe input instead, e.g. cat report.txt | eris yak issue --summary "…" --${label}-file -`,
|
|
191
|
+
slug: "yak-stdin-is-a-tty",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
return { value: await deps.readStdin() };
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return {
|
|
198
|
+
reason: `cannot read stdin for --${label}-file -: ${(err as Error).message}`,
|
|
199
|
+
slug: `yak-${label}-file-unreadable` as InputMisuseSlug,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
return { value: await deps.readFile(filePath) };
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return {
|
|
208
|
+
reason: `cannot read --${label}-file ${filePath}: ${(err as Error).message}`,
|
|
209
|
+
slug: `yak-${label}-file-unreadable` as InputMisuseSlug,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resolve `summary`/`body`/`evidence` from the parsed flags. Required-field
|
|
216
|
+
* and mutual-exclusion checks run BEFORE any file/stdin I/O so a doomed
|
|
217
|
+
* call never blocks on a read it was always going to reject — in
|
|
218
|
+
* particular, the `--body-file - && --evidence-file -` conflict is
|
|
219
|
+
* checked first so neither read is ever attempted (only one stdin
|
|
220
|
+
* exists to give either of them).
|
|
221
|
+
*/
|
|
222
|
+
export async function resolveTextInputs(
|
|
223
|
+
raw: RawTextInputs,
|
|
224
|
+
depsOverrides: Partial<TextInputDeps> = {},
|
|
225
|
+
): Promise<IdentityOutcome<ResolvedTextInputs, InputMisuseSlug>> {
|
|
226
|
+
const deps = defaultTextInputDeps(depsOverrides);
|
|
227
|
+
|
|
228
|
+
if (raw.bodyFile === "-" && raw.evidenceFile === "-") {
|
|
229
|
+
return {
|
|
230
|
+
reason: "--body-file - and --evidence-file - both read stdin — only one stdin exists; pass the other as --evidence <text> or a real file path",
|
|
231
|
+
slug: "yak-stdin-conflict",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (raw.summary === undefined || raw.summary.length === 0) {
|
|
236
|
+
return { reason: "missing required --summary", slug: "yak-summary-required" };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const bodyOutcome = await resolveOneField("body", raw.bodyInline, raw.bodyFile, deps);
|
|
240
|
+
if ("reason" in bodyOutcome) return bodyOutcome;
|
|
241
|
+
if (bodyOutcome.value === undefined || bodyOutcome.value.length === 0) {
|
|
242
|
+
return { reason: "missing required --body or --body-file", slug: "yak-body-required" };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const evidenceOutcome = await resolveOneField("evidence", raw.evidenceInline, raw.evidenceFile, deps);
|
|
246
|
+
if ("reason" in evidenceOutcome) return evidenceOutcome;
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
value: {
|
|
250
|
+
summary: raw.summary,
|
|
251
|
+
body: bodyOutcome.value,
|
|
252
|
+
evidence: evidenceOutcome.value,
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Redaction summary — shared shape for the delivered/warn/misuse outcomes
|
|
259
|
+
// and the `--output json` record's `redactions: {count, classes}` field.
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
export interface RedactionSummary {
|
|
263
|
+
count: number;
|
|
264
|
+
classes: RedactionClass[];
|
|
265
|
+
/** True when `--no-redact` was set for THIS submission — the scrub pass
|
|
266
|
+
* never ran at all. Always present, never inferred from `count === 0`:
|
|
267
|
+
* a skipped pass and a pass that ran and found nothing both leave
|
|
268
|
+
* `count` at zero, and without this field the two are indistinguishable
|
|
269
|
+
* on the wire (output-contract fix — `--output json` used to
|
|
270
|
+
* print the `--no-redact` WARN on stderr as the only record of the
|
|
271
|
+
* skip; suppressing that line unconditionally would have silently lost
|
|
272
|
+
* the fact for every `--output json` caller). */
|
|
273
|
+
skipped: boolean;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function zeroRedactionSummary(skipped = false): RedactionSummary {
|
|
277
|
+
return { count: 0, classes: [], skipped };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The canonical zero-filled `Record<RedactionClass, number>` — derived
|
|
281
|
+
* from `redact("")` rather than re-declaring the class list here, so
|
|
282
|
+
* this file has exactly one dependency on `yak-redact.ts`'s class set
|
|
283
|
+
* (its own export) instead of a second, driftable copy. */
|
|
284
|
+
function zeroRedactionCounts(): Record<RedactionClass, number> {
|
|
285
|
+
return redact("").counts;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function mergeRedactionCounts(
|
|
289
|
+
parts: Array<Record<RedactionClass, number> | undefined>,
|
|
290
|
+
): Record<RedactionClass, number> {
|
|
291
|
+
const merged = zeroRedactionCounts();
|
|
292
|
+
for (const part of parts) {
|
|
293
|
+
if (!part) continue;
|
|
294
|
+
for (const cls of Object.keys(merged) as RedactionClass[]) {
|
|
295
|
+
merged[cls] += part[cls];
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return merged;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function summarizeRedactionCounts(
|
|
302
|
+
counts: Record<RedactionClass, number> | undefined,
|
|
303
|
+
skipped: boolean,
|
|
304
|
+
): RedactionSummary {
|
|
305
|
+
if (!counts) return zeroRedactionSummary(skipped);
|
|
306
|
+
const classes = (Object.keys(counts) as RedactionClass[]).filter((c) => counts[c] > 0);
|
|
307
|
+
const count = classes.reduce((acc, c) => acc + counts[c], 0);
|
|
308
|
+
return { count, classes, skipped };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Submission core
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
/** One bounded request, no retry: 30s, the same bound as a
|
|
316
|
+
* `curl --max-time 30` call to the endpoint. */
|
|
317
|
+
export const DEFAULT_SUBMIT_TIMEOUT_MS = 30_000;
|
|
318
|
+
|
|
319
|
+
const YAKS_INGRESS_PATH = "/v1/yaks";
|
|
320
|
+
|
|
321
|
+
/** Warn-class slugs (environment/config/transport — never the
|
|
322
|
+
* agent's fault, so `yak.ts` maps every one of these to `WARN` + exit 0,
|
|
323
|
+
* never the exit-1 failure block). The network-outcome ones come from a fetch outcome this module
|
|
324
|
+
* observes directly. `yak-identity-id-too-long` and `yak-redaction-overflow`
|
|
325
|
+
* are both `too_big` zod failures on
|
|
326
|
+
* step 5, and both were previously misclassified as agent misuse even
|
|
327
|
+
* though neither is caused by anything the `eris yak` invocation itself
|
|
328
|
+
* controls — `device_id`/`execution_id` are resolved from an env var or a
|
|
329
|
+
* state file the caller never sees, and a redaction-caused overflow means
|
|
330
|
+
* the caller's ORIGINAL text was in bounds until our own scrub pass grew
|
|
331
|
+
* it past the limit. */
|
|
332
|
+
export type SubmitWarnSlug =
|
|
333
|
+
| "yak-unreachable"
|
|
334
|
+
| "yak-timeout"
|
|
335
|
+
| "yak-unauthorized"
|
|
336
|
+
| "yak-not-entitled"
|
|
337
|
+
| "yak-payload-too-large"
|
|
338
|
+
| "yak-transport-error"
|
|
339
|
+
| "yak-identity-id-too-long"
|
|
340
|
+
| "yak-redaction-overflow";
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The endpoint's refusals that no retry fixes and the operator can act on:
|
|
344
|
+
* its HTTP status -> the warn slug plus the local remedy the reason ends with.
|
|
345
|
+
* Every other non-2xx stays `yak-transport-error` (an outage, a missing route,
|
|
346
|
+
* a proxy page), because no local remedy is known for it. All are WARN + exit
|
|
347
|
+
* 0 like the rest of this union.
|
|
348
|
+
*
|
|
349
|
+
* The remedies are local ones only. The reasons do not point at the vendor
|
|
350
|
+
* route; `docs/ERRORS.md` carries that pointer.
|
|
351
|
+
*/
|
|
352
|
+
const REFUSAL_BY_STATUS: ReadonlyMap<number, { slug: SubmitWarnSlug; remedy: string }> = new Map([
|
|
353
|
+
[
|
|
354
|
+
401,
|
|
355
|
+
{
|
|
356
|
+
slug: "yak-unauthorized",
|
|
357
|
+
remedy:
|
|
358
|
+
"the endpoint rejected the bearer token; check the token in ~/.eris/yaks.json or ERIS_YAKS_TOKEN, and that it is registered with the endpoint",
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
[
|
|
362
|
+
402,
|
|
363
|
+
{
|
|
364
|
+
slug: "yak-not-entitled",
|
|
365
|
+
remedy: "this workspace is not entitled to Yaks (its plan or seat does not include it)",
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
[
|
|
369
|
+
413,
|
|
370
|
+
{
|
|
371
|
+
slug: "yak-payload-too-large",
|
|
372
|
+
remedy: "the submission exceeded the endpoint's whole-body size cap; trim --evidence, --repro or the --repo hints",
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
]);
|
|
376
|
+
|
|
377
|
+
export type SubmitMisuseSlug =
|
|
378
|
+
| "yak-summary-too-long"
|
|
379
|
+
| "yak-body-too-long"
|
|
380
|
+
| "yak-evidence-too-long"
|
|
381
|
+
| "yak-repro-too-long"
|
|
382
|
+
| "yak-repo-too-long"
|
|
383
|
+
| "yak-repo-hints-too-many"
|
|
384
|
+
| "yak-payload-invalid";
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Local length ceiling for `--repro <command>`. `repro` rides
|
|
388
|
+
* inside `provenance` (see `SubmitParams.repro`), which the contract
|
|
389
|
+
* validates only for its two typed keys (`repo.target_repos`,
|
|
390
|
+
* `execution.rung`) — "never validated as a gate" is `YakProvenance`'s own
|
|
391
|
+
* documented posture for the rest, because it's enrichment, not payload. A
|
|
392
|
+
* repro command is agent-authored free text
|
|
393
|
+
* like `summary`/`body`/`evidence`, though, so it gets the same local
|
|
394
|
+
* courtesy bound those three get (`docs/YAK.md`: "over-length fields fail
|
|
395
|
+
* locally... rather than letting the server truncate silently") even though
|
|
396
|
+
* nothing server-side would ever truncate it — this is about keeping a
|
|
397
|
+
* pasted multi-command script or a stray heredoc out of the server storage this
|
|
398
|
+
* repo doesn't control, not about the wire contract. Sized well above any
|
|
399
|
+
* real repro invocation (a shell one-liner, a short pytest/vitest selector,
|
|
400
|
+
* an `eris`/`curl` repro command) while still well under `YAK_BODY_MAX_LENGTH`
|
|
401
|
+
* (4,000) — a repro is a command, not a narrative.
|
|
402
|
+
*/
|
|
403
|
+
export const YAK_REPRO_MAX_LENGTH = 2_000;
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Bounds for `--repo <owner/name>` target-repo hints (repeatable —
|
|
407
|
+
* see `SubmitParams.repoHints`). A hint rides inside
|
|
408
|
+
* `provenance.repo.target_repos`, which the contract package types and
|
|
409
|
+
* bounds (`YakRepoProvenance`): the same constants are re-exported here so
|
|
410
|
+
* this file's local check and the contract's wire check are one number, not
|
|
411
|
+
* two that can drift. The local check still runs first so an over-bound hint
|
|
412
|
+
* fails with an actionable `--repo` message rather than a generic
|
|
413
|
+
* `yak-payload-invalid`. A hint is a short `owner/name` identifier (this CLI
|
|
414
|
+
* never validates it against a live repo list — see that field's own
|
|
415
|
+
* docstring), so 200 chars is generous headroom over any real one, and 20
|
|
416
|
+
* hints is generous headroom over any single report that genuinely spans
|
|
417
|
+
* that many repos.
|
|
418
|
+
*/
|
|
419
|
+
export { YAK_REPO_HINT_MAX_LENGTH, YAK_REPO_HINTS_MAX_COUNT };
|
|
420
|
+
|
|
421
|
+
export type SubmitResult =
|
|
422
|
+
| {
|
|
423
|
+
status: "delivered";
|
|
424
|
+
receipt: YakReceiptResponse;
|
|
425
|
+
idempotencyKey: string;
|
|
426
|
+
/** True when our LOCALLY-derived key disagrees with the receipt's
|
|
427
|
+
* authoritative one — the only canonicalization-drift signal this
|
|
428
|
+
* contract exposes. Still a successful delivery; `yak.ts` surfaces this
|
|
429
|
+
* as an additional warning, not a different exit code. */
|
|
430
|
+
keyMismatch: boolean;
|
|
431
|
+
redaction: RedactionSummary;
|
|
432
|
+
}
|
|
433
|
+
| {
|
|
434
|
+
status: "warn";
|
|
435
|
+
slug: SubmitWarnSlug;
|
|
436
|
+
reason: string;
|
|
437
|
+
redaction?: RedactionSummary;
|
|
438
|
+
/** Present whenever the key was already derived before the warn
|
|
439
|
+
* fired (every network-outcome warn below) — absent for warns
|
|
440
|
+
* raised earlier in the pipeline (token/url unresolved), where no
|
|
441
|
+
* payload was ever built. */
|
|
442
|
+
idempotencyKey?: string;
|
|
443
|
+
}
|
|
444
|
+
| {
|
|
445
|
+
status: "misuse";
|
|
446
|
+
slug: SubmitMisuseSlug;
|
|
447
|
+
reason: string;
|
|
448
|
+
/** Always present: every `SubmitMisuseSlug` this module raises is a
|
|
449
|
+
* zod validation failure on step 5, which runs AFTER key
|
|
450
|
+
* derivation (step 3) — the key exists, it just describes a
|
|
451
|
+
* payload the schema rejected. */
|
|
452
|
+
idempotencyKey: string;
|
|
453
|
+
/** Also always present, for the same reason: step 5 runs AFTER step
|
|
454
|
+
* 2 (redact). `yak-summary-too-long` /
|
|
455
|
+
* `yak-body-too-long` / `yak-evidence-too-long`'s own `reason` text
|
|
456
|
+
* says "redaction may have grown it; check with --no-redact" — that
|
|
457
|
+
* claim needs the actual count/classes sitting beside it, on the
|
|
458
|
+
* SAME outcome, or the message tells the reader to check evidence
|
|
459
|
+
* the misuse result never carried. */
|
|
460
|
+
redaction: RedactionSummary;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
export interface SubmitParams {
|
|
464
|
+
verb: YakVerb;
|
|
465
|
+
summary: string;
|
|
466
|
+
body: string;
|
|
467
|
+
/** Raw (pre-redaction) evidence text, or absent for "no evidence was
|
|
468
|
+
* given at all". An empty string here (an `--evidence-file` that
|
|
469
|
+
* resolved to zero bytes) is normalized to absent AFTER redaction —
|
|
470
|
+
* see step 4 in the file header. */
|
|
471
|
+
evidence?: string;
|
|
472
|
+
/** Raw (pre-redaction) `--repro <command>` text, or absent when
|
|
473
|
+
* no repro was given — a yak is not required to carry one. Redacted the
|
|
474
|
+
* same as `evidence` (a repro command can itself embed a credential, e.g.
|
|
475
|
+
* `curl -H "Authorization: Bearer …"`), bounded the same way (see
|
|
476
|
+
* `YAK_REPRO_MAX_LENGTH` below) and normalized empty-after-redaction to
|
|
477
|
+
* absent (the same rule `evidence` already follows). It is NOT part of
|
|
478
|
+
* the `YakSubmission` shape — the wire contract is the workspace package
|
|
479
|
+
* `@sibylline-labs/yak-contract`, which this CLI consumes and does not
|
|
480
|
+
* extend on its own — so it rides inside `provenance`
|
|
481
|
+
* instead, which `YakProvenance.catchall(z.unknown())` was built to carry
|
|
482
|
+
* exactly this kind of forward-compatible, not-yet-contracted field (see
|
|
483
|
+
* that type's own docstring: "a CLI that learns an unforeseen provenance
|
|
484
|
+
* category does not have to wait on a contract bump to send it"). Lands on
|
|
485
|
+
* the wire as `provenance.repro.command`. This CLI never executes it —
|
|
486
|
+
* submission is the whole of this module's contract with the string; any
|
|
487
|
+
* rerun is the Yaks endpoint's concern, sandboxed on that side
|
|
488
|
+
* (docs/YAK.md#repro). */
|
|
489
|
+
repro?: string;
|
|
490
|
+
/** Raw (pre-redaction) `--repo <owner/name>` hints, repeatable
|
|
491
|
+
* — zero or more target-repo hints naming the repo(s) this report
|
|
492
|
+
* concerns, so a dispatch/triage flow can read a target repo directly
|
|
493
|
+
* off the rendered issue instead of running a code search across the
|
|
494
|
+
* whole org. Redacted the same as
|
|
495
|
+
* `repro` and bounded the same way (see `YAK_REPO_HINT_MAX_LENGTH` /
|
|
496
|
+
* `YAK_REPO_HINTS_MAX_COUNT` below). NOT validated against a live repo
|
|
497
|
+
* list — an opaque hint, exactly like `repro` is an opaque command: this
|
|
498
|
+
* CLI never confirms the named repo actually exists.
|
|
499
|
+
*
|
|
500
|
+
* When absent or empty (no `--repo` given at all), `submitYak`
|
|
501
|
+
* auto-derives a single hint from `provenance.git.origin_url` — the same
|
|
502
|
+
* git remote `plugins/yak/yak-context.ts::gatherGit` already probes for every
|
|
503
|
+
* submission — so an agent filing from inside a repo checkout gets a
|
|
504
|
+
* hint without passing anything. Explicit `--repo` always wins over that
|
|
505
|
+
* derivation; the two are never merged together. It is NOT part of the
|
|
506
|
+
* `YakSubmission` top-level shape — like `repro`, it rides inside
|
|
507
|
+
* `provenance`, but unlike `repro` it lands in the ALREADY-typed `repo`
|
|
508
|
+
* category (`YakProvenance.repo`, which `yak-context.ts::gatherRepo`
|
|
509
|
+
* already populates with `service`) rather than a new catchall key — a
|
|
510
|
+
* target-repo hint is repo-scoped provenance, not a new kind of fact. Lands on the wire as
|
|
511
|
+
* `provenance.repo.target_repos` (an array, even for one hint: a report
|
|
512
|
+
* that spans repos carries more than one).
|
|
513
|
+
* This CLI never validates any of it beyond the local length/count
|
|
514
|
+
* bounds; a dispatch/triage flow reads it off the rendered issue, which
|
|
515
|
+
* the Actor owns (docs/YAK.md's own "everything after the receipt...
|
|
516
|
+
* belongs to the Actor, not this CLI"). */
|
|
517
|
+
repoHints?: string[];
|
|
518
|
+
/** `--no-redact` skips the scrub entirely, with a loud
|
|
519
|
+
* warning — printing that warning is `yak.ts`'s job; this module just
|
|
520
|
+
* honours the flag. */
|
|
521
|
+
noRedact: boolean;
|
|
522
|
+
deviceId: string;
|
|
523
|
+
executionId: string;
|
|
524
|
+
provenance?: YakProvenance;
|
|
525
|
+
/** The resolved Yaks endpoint base URL (no trailing slash required — this
|
|
526
|
+
* module strips one if present) and bearer token. Both are ALREADY
|
|
527
|
+
* resolved by the time this is called: `yak.ts` maps a token/url
|
|
528
|
+
* resolution failure to a warn outcome itself, using
|
|
529
|
+
* `plugins/yak/yak-identity.ts`'s own typed failures, before this function
|
|
530
|
+
* is ever invoked. */
|
|
531
|
+
url: string;
|
|
532
|
+
token: string;
|
|
533
|
+
/** Injectable for tests — defaults to the global `fetch`. */
|
|
534
|
+
fetchImpl?: typeof fetch;
|
|
535
|
+
timeoutMs?: number;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** The pre-redaction raw values a `too_big` validation failure might name —
|
|
539
|
+
* needed to tell "the caller's own text was already too long" (misuse)
|
|
540
|
+
* apart from "our own resolution/redaction grew an in-bounds value past
|
|
541
|
+
* the limit" (warn). `device_id`/`execution_id` are never redacted, so
|
|
542
|
+
* their raw and post-redaction values are the same value either way. */
|
|
543
|
+
interface ValidationRawInputs {
|
|
544
|
+
summary: string;
|
|
545
|
+
body: string;
|
|
546
|
+
evidence: string | undefined;
|
|
547
|
+
deviceId: string;
|
|
548
|
+
executionId: string;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
type ValidationFailureOutcome =
|
|
552
|
+
| { status: "misuse"; slug: SubmitMisuseSlug; reason: string }
|
|
553
|
+
| { status: "warn"; slug: SubmitWarnSlug; reason: string };
|
|
554
|
+
|
|
555
|
+
function classifyValidationFailure(
|
|
556
|
+
issues: ZodIssue[],
|
|
557
|
+
raw: ValidationRawInputs,
|
|
558
|
+
redactedSummary: string,
|
|
559
|
+
redactedBody: string,
|
|
560
|
+
redactedEvidence: string | undefined,
|
|
561
|
+
): ValidationFailureOutcome {
|
|
562
|
+
const issue = issues[0]!;
|
|
563
|
+
const field = String(issue.path[0] ?? "(root)");
|
|
564
|
+
|
|
565
|
+
if (issue.code === "too_big") {
|
|
566
|
+
const max = typeof issue.maximum === "number" ? issue.maximum : Number(issue.maximum);
|
|
567
|
+
|
|
568
|
+
// device_id/execution_id: resolved from `~/.eris/device-id` or
|
|
569
|
+
// CLAUDE_CODE_SESSION_ID/CLAUDE_CODE_HOST_SESSION_ID/the shell ppid
|
|
570
|
+
// (plugins/yak/yak-identity.ts) — not a flag the `eris yak` caller passed,
|
|
571
|
+
// so an over-length value here is an identity-resolution problem, not
|
|
572
|
+
// agent misuse (a >200-char CLAUDE_CODE_SESSION_ID, or a corrupt
|
|
573
|
+
// device-id file).
|
|
574
|
+
if (field === "device_id" || field === "execution_id") {
|
|
575
|
+
const actualLength = field === "device_id" ? raw.deviceId.length : raw.executionId.length;
|
|
576
|
+
const source = field === "device_id" ? "~/.eris/device-id" : "CLAUDE_CODE_SESSION_ID/CLAUDE_CODE_HOST_SESSION_ID (or the shell ppid)";
|
|
577
|
+
return {
|
|
578
|
+
status: "warn",
|
|
579
|
+
slug: "yak-identity-id-too-long",
|
|
580
|
+
reason: `${field} is ${actualLength} chars, exceeds the max of ${max} — this comes from ${source}, not from anything this call passed; check that value, not the report`,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const isSummary = field === "summary";
|
|
585
|
+
const isEvidence = field === "evidence";
|
|
586
|
+
const rawLength = isSummary ? raw.summary.length : isEvidence ? (raw.evidence?.length ?? 0) : raw.body.length;
|
|
587
|
+
const redactedLength = isSummary
|
|
588
|
+
? redactedSummary.length
|
|
589
|
+
: isEvidence
|
|
590
|
+
? (redactedEvidence?.length ?? 0)
|
|
591
|
+
: redactedBody.length;
|
|
592
|
+
|
|
593
|
+
// The caller's ORIGINAL text was in bounds; the secret-scrub pass (a
|
|
594
|
+
// marker like «redacted:aws-key» is longer than most matches it
|
|
595
|
+
// replaces) is what pushed it over — our processing, not agent misuse.
|
|
596
|
+
if (rawLength <= max && redactedLength > max) {
|
|
597
|
+
return {
|
|
598
|
+
status: "warn",
|
|
599
|
+
slug: "yak-redaction-overflow",
|
|
600
|
+
reason: `${field} was ${rawLength} chars (within the ${max} max) before redaction, but the secret-scrub pass grew it to ${redactedLength} chars — the original text is fine; retry with --no-redact if it contains no real secret`,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const slug: SubmitMisuseSlug = isSummary ? "yak-summary-too-long" : isEvidence ? "yak-evidence-too-long" : "yak-body-too-long";
|
|
605
|
+
return {
|
|
606
|
+
status: "misuse",
|
|
607
|
+
slug,
|
|
608
|
+
reason: `${field} is ${redactedLength} chars, exceeds the max of ${max} — shorten it (redaction may have grown it; check with --no-redact) rather than relying on the server to truncate`,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return { status: "misuse", slug: "yak-payload-invalid", reason: `${field}: ${issue.message}` };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// Target-repo hints — `--repo <owner/name>`, repeatable
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
type RepoHintsCheckResult =
|
|
620
|
+
| { hints: string[] }
|
|
621
|
+
| { status: "warn"; slug: SubmitWarnSlug; reason: string }
|
|
622
|
+
| { status: "misuse"; slug: SubmitMisuseSlug; reason: string };
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Bound-check + normalize the explicit `--repo` hints (already redacted,
|
|
626
|
+
* 1:1 with the raw array so a redaction-overflow can still be traced back to
|
|
627
|
+
* the original length — same pairing `classifyValidationFailure` uses for
|
|
628
|
+
* summary/body/evidence). Empty/whitespace-only hints are dropped rather
|
|
629
|
+
* than counted or rejected (an agent passing `--repo ""` gets the same
|
|
630
|
+
* outcome as not passing `--repo` at all — see `resolveTargetRepoHints`,
|
|
631
|
+
* which is what decides whether that's "explicit, deliberately empty" or
|
|
632
|
+
* "fall back to auto-derivation"). Dedupes CASE-INSENSITIVELY on the final
|
|
633
|
+
* (redacted, trimmed) text: `--repo
|
|
634
|
+
* owner/repo --repo Owner/Repo` names the same repo on every host that
|
|
635
|
+
* matters (GitHub/GitLab/git remotes are not case-sensitive identity for
|
|
636
|
+
* this purpose) — preserving first-seen ORIGINAL casing and first-seen
|
|
637
|
+
* order, so a `--repo` typo'd (or re-cased) twice does not land as a
|
|
638
|
+
* two-element array with two entries for the same repo.
|
|
639
|
+
*/
|
|
640
|
+
function checkRepoHints(rawHints: string[] | undefined, redactedHints: string[] | undefined): RepoHintsCheckResult {
|
|
641
|
+
const redactedList = redactedHints ?? [];
|
|
642
|
+
const pairs = redactedList
|
|
643
|
+
.map((redactedText, i) => ({ raw: (rawHints?.[i] ?? redactedText).trim(), redacted: redactedText.trim() }))
|
|
644
|
+
.filter((p) => p.redacted.length > 0);
|
|
645
|
+
|
|
646
|
+
if (pairs.length > YAK_REPO_HINTS_MAX_COUNT) {
|
|
647
|
+
return {
|
|
648
|
+
status: "misuse",
|
|
649
|
+
slug: "yak-repo-hints-too-many",
|
|
650
|
+
reason: `${pairs.length} --repo hints given, exceeds the max of ${YAK_REPO_HINTS_MAX_COUNT} — narrow to the repos this report actually concerns`,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
for (const { raw, redacted } of pairs) {
|
|
655
|
+
if (redacted.length <= YAK_REPO_HINT_MAX_LENGTH) continue;
|
|
656
|
+
if (raw.length <= YAK_REPO_HINT_MAX_LENGTH) {
|
|
657
|
+
return {
|
|
658
|
+
status: "warn",
|
|
659
|
+
slug: "yak-redaction-overflow",
|
|
660
|
+
reason: `a --repo hint was ${raw.length} chars (within the ${YAK_REPO_HINT_MAX_LENGTH} max) before redaction, but the secret-scrub pass grew it to ${redacted.length} chars — the original text is fine; retry with --no-redact if it contains no real secret`,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
return {
|
|
664
|
+
status: "misuse",
|
|
665
|
+
slug: "yak-repo-too-long",
|
|
666
|
+
reason: `a --repo hint is ${redacted.length} chars, exceeds the max of ${YAK_REPO_HINT_MAX_LENGTH} — shorten it (redaction may have grown it; check with --no-redact) rather than relying on the server to truncate`,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const seen = new Set<string>();
|
|
671
|
+
const hints: string[] = [];
|
|
672
|
+
for (const { redacted } of pairs) {
|
|
673
|
+
const key = redacted.toLowerCase();
|
|
674
|
+
if (!seen.has(key)) {
|
|
675
|
+
seen.add(key);
|
|
676
|
+
hints.push(redacted); // preserve the first-seen occurrence's casing
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return { hints };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Best-effort `owner[/group.../]name` extraction from a git remote origin
|
|
684
|
+
* URL — for auto-populating a target-repo hint when no `--repo` was given at
|
|
685
|
+
* all. Structurally parses the shapes `git remote -v` actually
|
|
686
|
+
* produces — `scheme://[user@]host/path(.git)?` (https, ssh, git, …),
|
|
687
|
+
* `user@host:path(.git)?` (scp-like syntax) — via `new URL()` for the former
|
|
688
|
+
* (verified: WHATWG's parser splits host from path correctly for `ssh://`
|
|
689
|
+
* and `git://` too, not only the "special" schemes) and a literal split for
|
|
690
|
+
* the latter, rather than a single scheme-agnostic pattern. A
|
|
691
|
+
* scheme-agnostic pattern is silently WRONG on two real shapes, not merely
|
|
692
|
+
* conservative:
|
|
693
|
+
*
|
|
694
|
+
* 1. It kept only the LAST TWO path segments, so a GitLab-style nested
|
|
695
|
+
* group truncates: `https://gitlab.example.com/group/subgroup/project`
|
|
696
|
+
* derived `"subgroup/project"`, silently dropping `group` — and
|
|
697
|
+
* nothing on the wire marks an auto-derived hint as auto-derived, so
|
|
698
|
+
* this wrong-but-plausible value would have reached
|
|
699
|
+
* `provenance.repo.target_repos` looking exactly as authoritative as a
|
|
700
|
+
* hand-typed one. Fixed by taking the FULL path (every segment) once a
|
|
701
|
+
* real host is structurally identified, never just a trailing two.
|
|
702
|
+
* 2. It could not tell a remote from a local checkout: `/home/user/repos/
|
|
703
|
+
* myproject` and `file:///home/user/repos/myproject` both derived
|
|
704
|
+
* `"repos/myproject"` — a fabricated, plausible-looking owner that is
|
|
705
|
+
* really just two path components of a local filesystem path. Fixed by
|
|
706
|
+
* refusing `file://` and any local-filesystem-shaped string (absolute,
|
|
707
|
+
* `./`/`../`-relative, or a Windows drive path) outright, rather than
|
|
708
|
+
* guessing from ANY 2+-segment string.
|
|
709
|
+
*
|
|
710
|
+
* Still degrades gracefully (returns `undefined`, never throws) on a shape
|
|
711
|
+
* with no reliable host at all: a bare relative local path, a hostname with
|
|
712
|
+
* no path, or a genuinely unparseable string. The one deliberate exception
|
|
713
|
+
* is a URL a redaction pass has already partly replaced with a
|
|
714
|
+
* `«redacted:…»` marker (`conn-string`'s pass replaces embedded
|
|
715
|
+
* `scheme://user:pass@host` credentials, leaving e.g.
|
|
716
|
+
* `«redacted:conn-string»/owner/name.git` as `origin_url`) — recovered
|
|
717
|
+
* conservatively, the LAST TWO segments only, because there is no
|
|
718
|
+
* structural signal for how many leading segments are real namespace vs.
|
|
719
|
+
* redaction residue in that one case.
|
|
720
|
+
*
|
|
721
|
+
* This hint is never validated against a live repo list (see
|
|
722
|
+
* `SubmitParams.repoHints`'s own docstring) — a wrong guess here is exactly
|
|
723
|
+
* as harmless as an agent typing the wrong `--repo` value, not a
|
|
724
|
+
* correctness bug to chase. The length bound is enforced by this function's
|
|
725
|
+
* one caller, `resolveTargetRepoHints`, not here.
|
|
726
|
+
*/
|
|
727
|
+
function deriveRepoHintFromGitUrl(url: string | undefined): string | undefined {
|
|
728
|
+
if (url === undefined) return undefined;
|
|
729
|
+
const trimmed = url.trim();
|
|
730
|
+
if (trimmed.length === 0) return undefined;
|
|
731
|
+
|
|
732
|
+
// Refuse anything that IS (or looks like) a local filesystem path rather
|
|
733
|
+
// than a remote. None of these have an "owner" at
|
|
734
|
+
// all; guessing one from trailing path components fabricates a
|
|
735
|
+
// plausible-looking but bogus hint.
|
|
736
|
+
if (/^file:\/\//i.test(trimmed)) return undefined;
|
|
737
|
+
if (trimmed.startsWith("/") || trimmed.startsWith("./") || trimmed.startsWith("../")) return undefined;
|
|
738
|
+
if (/^[A-Za-z]:[\\/]/.test(trimmed)) return undefined; // a Windows drive path, e.g. C:\ or C:/
|
|
739
|
+
|
|
740
|
+
let pathPart: string | undefined;
|
|
741
|
+
|
|
742
|
+
// scp-like syntax: user@host:path — git@github.com:group/name(.git)
|
|
743
|
+
const scpMatch = /^[\w.-]+@[\w.-]+:(.+)$/.exec(trimmed);
|
|
744
|
+
if (scpMatch) {
|
|
745
|
+
pathPart = scpMatch[1];
|
|
746
|
+
} else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
|
|
747
|
+
// A real scheme://host/path URL (https, ssh, git, …).
|
|
748
|
+
try {
|
|
749
|
+
pathPart = new URL(trimmed).pathname;
|
|
750
|
+
} catch {
|
|
751
|
+
return undefined;
|
|
752
|
+
}
|
|
753
|
+
} else if (trimmed.includes("«redacted:")) {
|
|
754
|
+
// See the file header's "one deliberate exception" — a redacted-remnant
|
|
755
|
+
// URL, recovered conservatively (last two segments only).
|
|
756
|
+
const m = /([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*?)(?:\.git)?\/?$/.exec(trimmed);
|
|
757
|
+
return m ? `${m[1]}/${m[2]}` : undefined;
|
|
758
|
+
} else {
|
|
759
|
+
// No scheme, no scp syntax, no redaction marker — no structural signal
|
|
760
|
+
// this was ever a remote at all (e.g. a bare relative local path like
|
|
761
|
+
// `repos/myproject`). Refuse rather than guess.
|
|
762
|
+
return undefined;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (pathPart === undefined) return undefined;
|
|
766
|
+
const segments = pathPart
|
|
767
|
+
.replace(/\.git\/?$/, "")
|
|
768
|
+
.split("/")
|
|
769
|
+
.filter((s) => s.length > 0);
|
|
770
|
+
// The FULL path once a real host is identified, not
|
|
771
|
+
// just the trailing two segments — a GitLab-style nested group must not
|
|
772
|
+
// be silently truncated.
|
|
773
|
+
if (segments.length < 2) return undefined;
|
|
774
|
+
return segments.join("/");
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Resolve the final target-repo hint list for this submission: explicit
|
|
779
|
+
* `--repo` hints always win when ANY were given (even if every one of them
|
|
780
|
+
* turns out empty/whitespace-only after trimming — an agent that passed
|
|
781
|
+
* `--repo` at all made a deliberate choice not to rely on auto-derivation),
|
|
782
|
+
* falling back to a single git-remote-derived hint only when `--repo` was
|
|
783
|
+
* never passed. Returns `undefined` — never `[]` — when there is nothing to
|
|
784
|
+
* report either way (absent, not empty: nobody looked and found none,
|
|
785
|
+
* derivation just never had anything to go on).
|
|
786
|
+
*
|
|
787
|
+
* The auto-derived branch
|
|
788
|
+
* must clear the SAME `YAK_REPO_HINT_MAX_LENGTH` bound an explicit `--repo`
|
|
789
|
+
* hint is checked against in `checkRepoHints` — confirmed live, a crafted
|
|
790
|
+
* `origin_url` with a 50,000-char path segment reached the transport with
|
|
791
|
+
* no bound check firing at all, because `checkRepoHints` only ever runs
|
|
792
|
+
* over `params.repoHints` (explicit) and this branch bypassed it entirely.
|
|
793
|
+
* An auto-derived hint isn't agent-authored text, so an over-length one is
|
|
794
|
+
* not misuse and must not fail the whole submission (the same reasoning
|
|
795
|
+
* `device_id`/`execution_id` get for `yak-identity-id-too-long`) — but "not
|
|
796
|
+
* agent-authored" is not "exempt from the bound". Silently DROPPED rather
|
|
797
|
+
* than truncated: truncating an `owner/name` string is at least as likely
|
|
798
|
+
* to produce a different real repo's name as it is to produce something
|
|
799
|
+
* useful, and this is best-effort enrichment with an already-defined
|
|
800
|
+
* absent state (`undefined`) to fall back to — the same "nothing to report"
|
|
801
|
+
* outcome as an unparseable `origin_url`.
|
|
802
|
+
*/
|
|
803
|
+
function resolveTargetRepoHints(
|
|
804
|
+
rawHints: string[] | undefined,
|
|
805
|
+
checked: RepoHintsCheckResult,
|
|
806
|
+
originUrl: unknown,
|
|
807
|
+
): string[] | undefined {
|
|
808
|
+
if (rawHints !== undefined && rawHints.length > 0) {
|
|
809
|
+
// Explicit was given — "hints" is the only reachable branch of
|
|
810
|
+
// `checked` here (a warn/misuse always returns before this function is
|
|
811
|
+
// ever called; see the call site in `submitYak`).
|
|
812
|
+
const hints = "hints" in checked ? checked.hints : [];
|
|
813
|
+
return hints.length > 0 ? hints : undefined;
|
|
814
|
+
}
|
|
815
|
+
const derived = deriveRepoHintFromGitUrl(typeof originUrl === "string" ? originUrl : undefined);
|
|
816
|
+
if (derived === undefined || derived.length > YAK_REPO_HINT_MAX_LENGTH) return undefined;
|
|
817
|
+
return [derived];
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* The submission pipeline (see file header for the exact, load-bearing
|
|
822
|
+
* step order). Never throws — every failure mode, local or remote, comes
|
|
823
|
+
* back as a typed `SubmitResult`.
|
|
824
|
+
*/
|
|
825
|
+
export async function submitYak(params: SubmitParams): Promise<SubmitResult> {
|
|
826
|
+
const fetchImpl = params.fetchImpl ?? fetch;
|
|
827
|
+
const timeoutMs = params.timeoutMs ?? DEFAULT_SUBMIT_TIMEOUT_MS;
|
|
828
|
+
|
|
829
|
+
// --- step 2: redact (BEFORE key derivation) ------------------------------
|
|
830
|
+
let redactedSummary = params.summary;
|
|
831
|
+
let redactedBody = params.body;
|
|
832
|
+
let redactedEvidence = params.evidence;
|
|
833
|
+
let redactedRepro = params.repro;
|
|
834
|
+
// redacted 1:1 with `params.repoHints` — same array shape and
|
|
835
|
+
// order, just each element passed through `redact()`. `undefined` when no
|
|
836
|
+
// `--repo` was given at all, the same absent-vs-empty posture `repro`
|
|
837
|
+
// already follows.
|
|
838
|
+
let redactedRepoHints = params.repoHints;
|
|
839
|
+
let redactedProvenance = params.provenance;
|
|
840
|
+
let redactionCounts: Record<RedactionClass, number> | undefined;
|
|
841
|
+
|
|
842
|
+
if (!params.noRedact) {
|
|
843
|
+
const summaryR = redact(params.summary);
|
|
844
|
+
const bodyR = redact(params.body);
|
|
845
|
+
const evidenceR = params.evidence !== undefined ? redact(params.evidence) : undefined;
|
|
846
|
+
const reproR = params.repro !== undefined ? redact(params.repro) : undefined;
|
|
847
|
+
const repoHintsR = params.repoHints !== undefined ? params.repoHints.map((h) => redact(h)) : undefined;
|
|
848
|
+
const provenanceR = params.provenance !== undefined ? redactDeep(params.provenance) : undefined;
|
|
849
|
+
|
|
850
|
+
redactedSummary = summaryR.text;
|
|
851
|
+
redactedBody = bodyR.text;
|
|
852
|
+
redactedEvidence = evidenceR?.text;
|
|
853
|
+
redactedRepro = reproR?.text;
|
|
854
|
+
redactedRepoHints = repoHintsR?.map((r) => r.text);
|
|
855
|
+
redactedProvenance = provenanceR?.value;
|
|
856
|
+
|
|
857
|
+
redactionCounts = mergeRedactionCounts([
|
|
858
|
+
summaryR.counts,
|
|
859
|
+
bodyR.counts,
|
|
860
|
+
evidenceR?.counts,
|
|
861
|
+
reproR?.counts,
|
|
862
|
+
...(repoHintsR?.map((r) => r.counts) ?? []),
|
|
863
|
+
provenanceR?.counts,
|
|
864
|
+
]);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const redaction = summarizeRedactionCounts(redactionCounts, params.noRedact);
|
|
868
|
+
|
|
869
|
+
// --- step 4: omit `evidence`/`repro` entirely when the (redacted) bytes
|
|
870
|
+
// are empty — applied here, before derivation, so step 3 below already
|
|
871
|
+
// sees the normalized value. Redaction can only ever replace a MATCH
|
|
872
|
+
// with a marker; it cannot turn empty text non-empty or vice versa, so
|
|
873
|
+
// checking "empty" on the redacted string is equivalent to checking the
|
|
874
|
+
// raw one and there is exactly one normalization point, not two.
|
|
875
|
+
const finalEvidence = redactedEvidence !== undefined && redactedEvidence.length > 0 ? redactedEvidence : undefined;
|
|
876
|
+
const finalRepro = redactedRepro !== undefined && redactedRepro.length > 0 ? redactedRepro : undefined;
|
|
877
|
+
|
|
878
|
+
// --- step 3: derive the idempotency key from the REDACTED, normalized
|
|
879
|
+
// payload — the Actor re-derives from what it actually received, so
|
|
880
|
+
// this must be the same text we're about to send, not the original.
|
|
881
|
+
// `repro` never participates: it isn't part of `canonicalPayload`
|
|
882
|
+
// (`@sibylline-labs/yak-contract`) — it rides inside `provenance`, which the
|
|
883
|
+
// key derivation deliberately excludes (only summary/body/evidence do,
|
|
884
|
+
// per that file's own docstring) — so attaching or changing a repro
|
|
885
|
+
// command never changes an observation's dedup identity.
|
|
886
|
+
const idempotencyKey = await deriveIdempotencyKey({
|
|
887
|
+
device_id: params.deviceId,
|
|
888
|
+
execution_id: params.executionId,
|
|
889
|
+
verb: params.verb,
|
|
890
|
+
summary: redactedSummary,
|
|
891
|
+
body: redactedBody,
|
|
892
|
+
evidence: finalEvidence,
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
// a repro command is agent-authored free text, like
|
|
896
|
+
// summary/body/evidence, so it gets the same local courtesy length bound
|
|
897
|
+
// those three get — see `YAK_REPRO_MAX_LENGTH`'s own docstring for why this
|
|
898
|
+
// is a courtesy (provenance isn't schema-bounded) rather than a wire-
|
|
899
|
+
// contract requirement. Checked here, after the key is derivable (every
|
|
900
|
+
// other misuse/warn this pipeline raises past this point carries one), and
|
|
901
|
+
// before `provenance` is built, so an over-bound repro never reaches the
|
|
902
|
+
// request at all — same "never let the server silently truncate it"
|
|
903
|
+
// posture as summary/body/evidence.
|
|
904
|
+
if (finalRepro !== undefined && finalRepro.length > YAK_REPRO_MAX_LENGTH) {
|
|
905
|
+
const rawReproLength = params.repro?.length ?? 0;
|
|
906
|
+
if (rawReproLength <= YAK_REPRO_MAX_LENGTH) {
|
|
907
|
+
return {
|
|
908
|
+
status: "warn",
|
|
909
|
+
slug: "yak-redaction-overflow",
|
|
910
|
+
reason: `repro was ${rawReproLength} chars (within the ${YAK_REPRO_MAX_LENGTH} max) before redaction, but the secret-scrub pass grew it to ${finalRepro.length} chars — the original text is fine; retry with --no-redact if it contains no real secret`,
|
|
911
|
+
redaction,
|
|
912
|
+
idempotencyKey,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
status: "misuse",
|
|
917
|
+
slug: "yak-repro-too-long",
|
|
918
|
+
reason: `repro is ${finalRepro.length} chars, exceeds the max of ${YAK_REPRO_MAX_LENGTH} — shorten it (redaction may have grown it; check with --no-redact) rather than relying on the server to truncate`,
|
|
919
|
+
idempotencyKey,
|
|
920
|
+
redaction,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// bound-check the explicit `--repo` hints the SAME way repro was
|
|
925
|
+
// just bound-checked above — an over-long or over-many hint never reaches
|
|
926
|
+
// the request at all (same posture). Auto-derivation from the
|
|
927
|
+
// git remote (`resolveTargetRepoHints`, below) is NEVER subject to these
|
|
928
|
+
// bounds — it isn't agent-authored text, same reasoning `device_id`/
|
|
929
|
+
// `execution_id` get for `yak-identity-id-too-long` above.
|
|
930
|
+
const repoHintsCheck = checkRepoHints(params.repoHints, redactedRepoHints);
|
|
931
|
+
if ("status" in repoHintsCheck) {
|
|
932
|
+
return { ...repoHintsCheck, idempotencyKey, redaction };
|
|
933
|
+
}
|
|
934
|
+
const finalRepoHints = resolveTargetRepoHints(params.repoHints, repoHintsCheck, redactedProvenance?.git?.origin_url);
|
|
935
|
+
|
|
936
|
+
// `repro` is not a field the contract's `YakSubmission` (or
|
|
937
|
+
// `YakProvenance`'s own typed categories) knows about — it rides in as an
|
|
938
|
+
// extra top-level provenance key, preserved on the wire by
|
|
939
|
+
// `YakProvenance.catchall(z.unknown())` (see `SubmitParams.repro`'s
|
|
940
|
+
// docstring). Merged in AFTER `redactedProvenance` is computed, using the
|
|
941
|
+
// ALREADY-redacted `finalRepro` from above — not a second `redactDeep`
|
|
942
|
+
// pass over it, which would be redundant (a marker never re-matches any
|
|
943
|
+
// class, `plugins/yak/yak-redact.ts`'s own idempotence guarantee) and would
|
|
944
|
+
// double-count it in `redactionCounts` if it weren't idempotent.
|
|
945
|
+
const provenanceWithRepro: YakProvenance | undefined =
|
|
946
|
+
finalRepro !== undefined
|
|
947
|
+
? { ...(redactedProvenance ?? {}), repro: { command: finalRepro } }
|
|
948
|
+
: redactedProvenance;
|
|
949
|
+
// unlike `repro`, a target-repo hint lands in the ALREADY-typed
|
|
950
|
+
// `repo` category (`YakProvenance.repo`) rather than a new catchall key —
|
|
951
|
+
// see `SubmitParams.repoHints`'s docstring for why. Merged in the same
|
|
952
|
+
// post-redaction style, preserving any sibling field that category
|
|
953
|
+
// already carries (`repo.service`, from `yak-context.ts::gatherRepo`).
|
|
954
|
+
const existingRepoCategory = provenanceWithRepro?.repo;
|
|
955
|
+
const provenanceWithRepoHints: YakProvenance | undefined =
|
|
956
|
+
finalRepoHints !== undefined && finalRepoHints.length > 0
|
|
957
|
+
? { ...(provenanceWithRepro ?? {}), repo: { ...(existingRepoCategory ?? {}), target_repos: finalRepoHints } }
|
|
958
|
+
: provenanceWithRepro;
|
|
959
|
+
const provenance =
|
|
960
|
+
provenanceWithRepoHints && Object.keys(provenanceWithRepoHints).length > 0 ? provenanceWithRepoHints : undefined;
|
|
961
|
+
|
|
962
|
+
// The reporter is never sent: the ingress derives the tracker user from the
|
|
963
|
+
// bearer token, so the contract has no field for one. `route: "workspace"`
|
|
964
|
+
// files the report into the caller's own tenant; it is not an input to the
|
|
965
|
+
// idempotency key.
|
|
966
|
+
const submission: YakSubmission = {
|
|
967
|
+
schema_version: YAKS_CONTRACT_VERSION,
|
|
968
|
+
verb: params.verb,
|
|
969
|
+
route: "workspace",
|
|
970
|
+
summary: redactedSummary,
|
|
971
|
+
body: redactedBody,
|
|
972
|
+
...(finalEvidence !== undefined ? { evidence: finalEvidence } : {}),
|
|
973
|
+
device_id: params.deviceId,
|
|
974
|
+
execution_id: params.executionId,
|
|
975
|
+
idempotency_key: idempotencyKey,
|
|
976
|
+
...(provenance ? { provenance } : {}),
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
// --- step 5: validate locally BEFORE the request ------------------------
|
|
980
|
+
const parsed = YakSubmission.safeParse(submission);
|
|
981
|
+
if (!parsed.success) {
|
|
982
|
+
const failure = classifyValidationFailure(
|
|
983
|
+
parsed.error.issues,
|
|
984
|
+
{ summary: params.summary, body: params.body, evidence: params.evidence, deviceId: params.deviceId, executionId: params.executionId },
|
|
985
|
+
redactedSummary,
|
|
986
|
+
redactedBody,
|
|
987
|
+
finalEvidence,
|
|
988
|
+
);
|
|
989
|
+
if (failure.status === "warn") {
|
|
990
|
+
return { status: "warn", slug: failure.slug, reason: failure.reason, redaction, idempotencyKey };
|
|
991
|
+
}
|
|
992
|
+
return { status: "misuse", slug: failure.slug, reason: failure.reason, idempotencyKey, redaction };
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// --- step 6: one bounded POST, no retry -----------------------------------
|
|
996
|
+
//
|
|
997
|
+
// The deadline covers the WHOLE exchange — connect, headers, AND body — not
|
|
998
|
+
// just the `fetchImpl()` call. `fetch()` in Bun/undici/browsers resolves as
|
|
999
|
+
// soon as headers arrive; `response.json()` then does a SEPARATE, unbounded
|
|
1000
|
+
// read of the body stream. A `finally` that cleared the timer right after
|
|
1001
|
+
// `fetchImpl()` (the original shape here) stopped covering the exchange the
|
|
1002
|
+
// instant headers landed — a server that sends headers with a
|
|
1003
|
+
// `content-length` and then never finishes the body would hang forever
|
|
1004
|
+
// past `timeoutMs`, the exact shape ("a command that never returns
|
|
1005
|
+
// mid-task") this file's own header promises `docs/YAK.md` does
|
|
1006
|
+
// not happen. `clearTimeout` now lives in a `finally` that wraps BOTH
|
|
1007
|
+
// awaits, so the same abort that would have covered only the headers phase
|
|
1008
|
+
// now also fires mid-`response.json()` and is caught by the second
|
|
1009
|
+
// try/catch below.
|
|
1010
|
+
const controller = new AbortController();
|
|
1011
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1012
|
+
const url = `${params.url.replace(/\/+$/, "")}${YAKS_INGRESS_PATH}`;
|
|
1013
|
+
|
|
1014
|
+
try {
|
|
1015
|
+
let response: Response;
|
|
1016
|
+
try {
|
|
1017
|
+
response = await fetchImpl(url, {
|
|
1018
|
+
method: "POST",
|
|
1019
|
+
headers: {
|
|
1020
|
+
"content-type": "application/json",
|
|
1021
|
+
authorization: `Bearer ${params.token}`,
|
|
1022
|
+
},
|
|
1023
|
+
body: JSON.stringify(parsed.data),
|
|
1024
|
+
signal: controller.signal,
|
|
1025
|
+
});
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
const name = (err as { name?: string }).name;
|
|
1028
|
+
if (name === "AbortError" || name === "TimeoutError") {
|
|
1029
|
+
return {
|
|
1030
|
+
status: "warn",
|
|
1031
|
+
slug: "yak-timeout",
|
|
1032
|
+
reason: `yaks endpoint did not respond within ${timeoutMs}ms (${url})`,
|
|
1033
|
+
redaction,
|
|
1034
|
+
idempotencyKey,
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
return {
|
|
1038
|
+
status: "warn",
|
|
1039
|
+
slug: "yak-unreachable",
|
|
1040
|
+
reason: `cannot reach yaks endpoint at ${url}: ${(err as Error).message}`,
|
|
1041
|
+
redaction,
|
|
1042
|
+
idempotencyKey,
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (!response.ok) {
|
|
1047
|
+
const refusal = REFUSAL_BY_STATUS.get(response.status);
|
|
1048
|
+
return {
|
|
1049
|
+
status: "warn",
|
|
1050
|
+
slug: refusal?.slug ?? "yak-transport-error",
|
|
1051
|
+
reason: `yaks endpoint responded ${response.status}${response.statusText ? ` ${response.statusText}` : ""} for ${url}${refusal ? ` — ${refusal.remedy}` : ""}`,
|
|
1052
|
+
redaction,
|
|
1053
|
+
idempotencyKey,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
let receiptJson: unknown;
|
|
1058
|
+
try {
|
|
1059
|
+
receiptJson = await response.json();
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
const name = (err as { name?: string }).name;
|
|
1062
|
+
if (name === "AbortError" || name === "TimeoutError") {
|
|
1063
|
+
return {
|
|
1064
|
+
status: "warn",
|
|
1065
|
+
slug: "yak-timeout",
|
|
1066
|
+
reason: `yaks endpoint did not finish sending its response body within ${timeoutMs}ms (${url})`,
|
|
1067
|
+
redaction,
|
|
1068
|
+
idempotencyKey,
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
return {
|
|
1072
|
+
status: "warn",
|
|
1073
|
+
slug: "yak-transport-error",
|
|
1074
|
+
reason: `yaks endpoint returned an unparseable ${response.status}: ${(err as Error).message}`,
|
|
1075
|
+
redaction,
|
|
1076
|
+
idempotencyKey,
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// LenientYakReceipt, not the package's YakReceipt directly — a
|
|
1081
|
+
// contract_version the package's literal doesn't know about (a bump the
|
|
1082
|
+
// server shipped before this CLI's package caught up) must not turn an
|
|
1083
|
+
// accepted, durably-stored delivery into a reported failure.
|
|
1084
|
+
const receiptParsed = LenientYakReceipt.safeParse(receiptJson);
|
|
1085
|
+
if (!receiptParsed.success) {
|
|
1086
|
+
return {
|
|
1087
|
+
status: "warn",
|
|
1088
|
+
slug: "yak-transport-error",
|
|
1089
|
+
reason: `yaks endpoint returned a ${response.status} that does not match the receipt contract`,
|
|
1090
|
+
redaction,
|
|
1091
|
+
idempotencyKey,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// --- step 7: compare our derived key against the receipt's -------------
|
|
1096
|
+
const keyMismatch = receiptParsed.data.idempotency_key !== idempotencyKey;
|
|
1097
|
+
|
|
1098
|
+
return {
|
|
1099
|
+
status: "delivered",
|
|
1100
|
+
receipt: receiptParsed.data,
|
|
1101
|
+
idempotencyKey,
|
|
1102
|
+
keyMismatch,
|
|
1103
|
+
redaction,
|
|
1104
|
+
};
|
|
1105
|
+
} finally {
|
|
1106
|
+
clearTimeout(timer);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// ---------------------------------------------------------------------------
|
|
1111
|
+
// `--output json` record
|
|
1112
|
+
// ---------------------------------------------------------------------------
|
|
1113
|
+
|
|
1114
|
+
export interface YakOutputRecord {
|
|
1115
|
+
verb: YakVerb;
|
|
1116
|
+
/** The field to branch on — NEVER `id == null` (same absent-vs-empty
|
|
1117
|
+
* discipline as `config show`'s tri-state `state` field). */
|
|
1118
|
+
delivered: boolean;
|
|
1119
|
+
id?: string;
|
|
1120
|
+
idempotency_key?: string;
|
|
1121
|
+
replay?: boolean;
|
|
1122
|
+
contract_version?: string;
|
|
1123
|
+
/** Present only when `delivered: true` — mirrors `SubmitResult`'s own
|
|
1124
|
+
* `keyMismatch` (output-contract fix). Before this field
|
|
1125
|
+
* existed, the only place the mismatch showed up was a `yak-key-mismatch`
|
|
1126
|
+
* WARN on stderr; suppressing every diagnostic under `--output json`
|
|
1127
|
+
* (this fix's whole point) would have made a canonicalization-drift
|
|
1128
|
+
* signal undetectable by a machine caller, since `idempotency_key` here
|
|
1129
|
+
* is the RECEIPT's key, never the locally-derived one the mismatch is
|
|
1130
|
+
* measured against. */
|
|
1131
|
+
key_mismatch?: boolean;
|
|
1132
|
+
redactions: RedactionSummary;
|
|
1133
|
+
error?: { slug: string; reason: string };
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
export function toOutputRecord(verb: YakVerb, result: SubmitResult): YakOutputRecord {
|
|
1137
|
+
if (result.status === "delivered") {
|
|
1138
|
+
return {
|
|
1139
|
+
verb,
|
|
1140
|
+
delivered: true,
|
|
1141
|
+
id: result.receipt.id,
|
|
1142
|
+
idempotency_key: result.receipt.idempotency_key,
|
|
1143
|
+
replay: result.receipt.replay,
|
|
1144
|
+
contract_version: result.receipt.contract_version,
|
|
1145
|
+
key_mismatch: result.keyMismatch,
|
|
1146
|
+
redactions: result.redaction,
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// "misuse" carries `redaction` same as
|
|
1151
|
+
// "warn" does (always present there — see the SubmitResult type) rather
|
|
1152
|
+
// than a silently-zeroed placeholder that would desync the JSON record's
|
|
1153
|
+
// `redactions` from the `reason` text a too-long slug prints ("redaction
|
|
1154
|
+
// may have grown it").
|
|
1155
|
+
const redactions =
|
|
1156
|
+
result.status === "warn" || result.status === "misuse"
|
|
1157
|
+
? (result.redaction ?? zeroRedactionSummary())
|
|
1158
|
+
: zeroRedactionSummary();
|
|
1159
|
+
return {
|
|
1160
|
+
verb,
|
|
1161
|
+
delivered: false,
|
|
1162
|
+
...(result.idempotencyKey !== undefined ? { idempotency_key: result.idempotencyKey } : {}),
|
|
1163
|
+
redactions,
|
|
1164
|
+
error: { slug: result.slug, reason: result.reason },
|
|
1165
|
+
};
|
|
1166
|
+
}
|