@allocator-one/harness-cli 1.10.0 → 1.12.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/README.md CHANGED
@@ -39,6 +39,37 @@ Headless contexts (agents, CI): mint an `aone_…` API token (admin → API
39
39
  tokens) and set `HARNESS_API_TOKEN`. Do not set it in interactive sessions —
40
40
  the browser login attributes writes to the real user.
41
41
 
42
+ ### Provider keys
43
+
44
+ Admins store the org's provider API keys (Anthropic, OpenAI, Google,
45
+ OpenRouter, Brave Search, Perplexity) in Harness under `Settings → Model
46
+ keys`. The CLI hands them to tooling without ever printing them:
47
+
48
+ - `harness keys list` — provider slugs the org has keys for and the env var
49
+ each maps to (`--json` for `{providers: [{provider, env}]}`); values are
50
+ never shown.
51
+ - `harness keys exec [--only <slugs>] [--override] -- <command> [args…]` —
52
+ runs the command with the keys in its environment and exits with the
53
+ command's exit code (128 + signal number when it was signalled). `--only
54
+ brave,perplexity` passes just those providers and withholds your shell's
55
+ own copies of the other provider variables, so the tool sees no more than
56
+ it needs; a requested provider the org has no key for keeps your shell's
57
+ variable (and says so on stderr). Slugs are trimmed and case-insensitive,
58
+ and an allowlist that names nothing (`--only ,`) is a usage error, not
59
+ "everything". Otherwise a variable already set in your shell wins unless
60
+ `--override` is given; a blank variable counts as unset. `--json` is
61
+ accepted for uniformity but changes nothing: stdout belongs to the command. A SIGTERM sent to
62
+ `harness` is forwarded to the command; Ctrl+C reaches the command from the
63
+ terminal directly and `harness` waits to report its exit. macOS and Linux
64
+ only.
65
+
66
+ Slug → variable, the same table rcl uses: anthropic → `ANTHROPIC_API_KEY`,
67
+ openai → `OPENAI_API_KEY`, google → `GEMINI_API_KEY`, openrouter →
68
+ `OPENROUTER_API_KEY`, brave → `BRAVE_API_KEY`, perplexity →
69
+ `PERPLEXITY_API_KEY`. Reading keys needs the `model-keys:read` scope: login
70
+ tokens carry it, `aone_` tokens must be minted with it. There is deliberately
71
+ no MCP tool for this — a tool result is model-visible text.
72
+
42
73
  ## Environment variables
43
74
 
44
75
  | Variable | Effect |
@@ -147,6 +178,23 @@ error rather than a silent precedence, and only one field per invocation may
147
178
  read stdin. Both are checked before anything is read, so a rejected command
148
179
  never consumes your pipe. One trailing newline is dropped, like `$(cat file)`.
149
180
 
181
+ ## Image and video uploads
182
+
183
+ `harness upload ./demo.mp4` prints a reusable markdown reference. Paste it
184
+ into an issue description or comment to show an inline video player. Use
185
+ `--json` for the server's URL, filename, content type, byte size, and markdown.
186
+ The MCP `upload` tool accepts the same local path and returns the same metadata.
187
+
188
+ Images (PNG, JPG, WebP, GIF) can be up to 25 MB. Videos must be `.mp4`, up to
189
+ 100 MB, with H.264 8-bit 4:2:0 video, optional AAC audio, and optional mov_text
190
+ subtitles. The server validates the media; it does not transcode other formats.
191
+
192
+ A newly uploaded video is private to its uploader until that user saves its
193
+ link in an issue description or comment. It then follows the owning issue's
194
+ access policy. Reusing the link elsewhere does not change that ownership.
195
+ `harness show` continues downloading images only, never linked videos.
196
+ The older `attach-image` command retains its 25 MB limit.
197
+
150
198
  ## Repo config
151
199
 
152
200
  A committed `.harness-cli/config.json`, discovered git-style by walking up
@@ -444,8 +444,8 @@ function path(record, ...keys) {
444
444
  }
445
445
 
446
446
  // src/commands/issues.ts
447
- import { openSync, closeSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
448
- import { basename } from "path";
447
+ import { openAsBlob, openSync, closeSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
448
+ import { basename, extname } from "path";
449
449
 
450
450
  // src/core/attachments.ts
451
451
  import { chmodSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
@@ -1090,19 +1090,28 @@ function requireCommentId(commentId) {
1090
1090
  throw new UsageError(`not a comment id: ${commentId} (expected a UUID)`);
1091
1091
  }
1092
1092
  }
1093
- var MAX_ATTACHMENT_BYTES2 = 25e6;
1093
+ var MAX_IMAGE_BYTES = 25e6;
1094
+ var MAX_VIDEO_BYTES = 1e8;
1094
1095
  async function attachImage(ctx, path2) {
1095
- attachableFile(path2);
1096
- const bytes = readFileSync2(path2);
1096
+ return uploadAttachment(ctx, path2, MAX_IMAGE_BYTES);
1097
+ }
1098
+ async function attachFile(ctx, path2) {
1099
+ const maxBytes = extname(path2).toLowerCase() === ".mp4" ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
1100
+ return uploadAttachment(ctx, path2, maxBytes);
1101
+ }
1102
+ async function uploadAttachment(ctx, path2, maxBytes) {
1103
+ attachableFile(path2, maxBytes);
1104
+ const file = maxBytes === MAX_VIDEO_BYTES ? new File([await openAsBlob(path2)], basename(path2)) : new File([readFileSync2(path2)], basename(path2));
1105
+ requireAttachmentSize(path2, file.size, maxBytes);
1097
1106
  const form = new FormData();
1098
- form.set("file", new File([bytes], basename(path2)));
1099
- const body = await ctx.client.postMultipart("/api/v1/uploads", form, bytes.byteLength);
1107
+ form.set("file", file);
1108
+ const body = await ctx.client.postMultipart("/api/v1/uploads", form, file.size);
1100
1109
  const attachment = body.data;
1101
1110
  const markdown = attachment["markdown"];
1102
1111
  if (typeof markdown !== "string") throw new CommandError("the upload response carried no markdown reference");
1103
1112
  return { json: attachment, human: clean(markdown) + "\n" };
1104
1113
  }
1105
- function attachableFile(path2) {
1114
+ function attachableFile(path2, maxBytes) {
1106
1115
  let fileStats;
1107
1116
  try {
1108
1117
  fileStats = statSync2(path2);
@@ -1113,17 +1122,18 @@ function attachableFile(path2) {
1113
1122
  const kind = fileStats.isDirectory() ? "directory" : "special file";
1114
1123
  throw new LocalError(`${path2} is a ${kind}, not a file.`);
1115
1124
  }
1116
- if (fileStats.size > MAX_ATTACHMENT_BYTES2) {
1117
- throw new LocalError(
1118
- `${path2} is too large: ${fileStats.size} bytes, and attachments are capped at ${MAX_ATTACHMENT_BYTES2}.`
1119
- );
1120
- }
1125
+ requireAttachmentSize(path2, fileStats.size, maxBytes);
1121
1126
  try {
1122
1127
  closeSync(openSync(path2, "r"));
1123
1128
  } catch (error) {
1124
1129
  throw new LocalError(`cannot read ${path2}: ${posixDetail(error)}`);
1125
1130
  }
1126
1131
  }
1132
+ function requireAttachmentSize(path2, size, maxBytes) {
1133
+ if (size > maxBytes) {
1134
+ throw new LocalError(`${path2} is too large: ${size} bytes, and attachments are capped at ${maxBytes}.`);
1135
+ }
1136
+ }
1127
1137
  function posixDetail(error) {
1128
1138
  const code = error.code;
1129
1139
  if (code === "ENOENT") return "no such file or directory";
@@ -1845,7 +1855,7 @@ function requireYes(command, id, yes) {
1845
1855
  if (yes !== true) throw new UsageError(`${command} ${id} is destructive \u2014 pass --yes to confirm`);
1846
1856
  }
1847
1857
  async function upload(ctx, path2) {
1848
- return attachImage(ctx, path2);
1858
+ return attachFile(ctx, path2);
1849
1859
  }
1850
1860
  function indent2(text) {
1851
1861
  return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
package/dist/cli.js CHANGED
@@ -82,12 +82,194 @@ import {
82
82
  version,
83
83
  workflowStates,
84
84
  writeCredentials
85
- } from "./chunk-KKAAKRKF.js";
85
+ } from "./chunk-WY6F3IJB.js";
86
86
 
87
87
  // src/cli.ts
88
88
  import { createInterface as createInterface2 } from "readline";
89
89
  import { Command, CommanderError, InvalidArgumentError } from "commander";
90
90
 
91
+ // src/commands/keys.ts
92
+ import { spawn } from "child_process";
93
+ import { constants } from "os";
94
+ var KEY_ENV_VARS = /* @__PURE__ */ new Map([
95
+ ["anthropic", "ANTHROPIC_API_KEY"],
96
+ ["openai", "OPENAI_API_KEY"],
97
+ ["google", "GEMINI_API_KEY"],
98
+ ["openrouter", "OPENROUTER_API_KEY"],
99
+ ["brave", "BRAVE_API_KEY"],
100
+ ["perplexity", "PERPLEXITY_API_KEY"]
101
+ ]);
102
+ function isRecord(value) {
103
+ return typeof value === "object" && value !== null && !Array.isArray(value);
104
+ }
105
+ async function fetchKeys(ctx) {
106
+ let body;
107
+ try {
108
+ body = await ctx.client.get("/api/v1/model-keys");
109
+ } catch (error) {
110
+ if (error instanceof HttpError && error.status === 403 && error.message.includes("model-keys:read")) {
111
+ throw new CommandError(
112
+ `${error.message} \u2014 login tokens carry model-keys:read; aone_ API tokens must be minted with it`
113
+ );
114
+ }
115
+ throw error;
116
+ }
117
+ const data = isRecord(body) ? body["data"] : void 0;
118
+ const keys2 = isRecord(data) ? data["keys"] : void 0;
119
+ if (!isRecord(keys2)) throw new CommandError("unexpected model-keys response: no keys map");
120
+ const stored = /* @__PURE__ */ new Map();
121
+ for (const [slug, value] of Object.entries(keys2)) {
122
+ const canonical = slug.trim().toLowerCase();
123
+ if (stored.has(canonical)) continue;
124
+ if (typeof value === "string" && value.trim() !== "" && !value.includes("\0")) stored.set(canonical, value);
125
+ }
126
+ return stored;
127
+ }
128
+ function envVarFor(slug) {
129
+ return KEY_ENV_VARS.get(slug.trim().toLowerCase());
130
+ }
131
+ function normalizeSlugs(only) {
132
+ return [...new Set(only.map((slug) => slug.trim().toLowerCase()).filter((slug) => slug !== ""))];
133
+ }
134
+ function selectSlugs(stored, only) {
135
+ if (only === void 0 || only.length === 0) return new Map(stored);
136
+ const wanted = new Set(only.map((slug) => slug.trim().toLowerCase()).filter((slug) => slug !== ""));
137
+ return new Map([...stored].filter(([slug]) => wanted.has(slug)));
138
+ }
139
+ function unknownSlugs(only) {
140
+ return only.map((slug) => slug.trim()).filter((slug) => slug !== "" && !KEY_ENV_VARS.has(slug.toLowerCase()));
141
+ }
142
+ function withholdOthers(base, requested) {
143
+ const keep = new Set(requested.map((slug) => envVarFor(slug)));
144
+ const env = { ...base };
145
+ for (const name of KEY_ENV_VARS.values()) {
146
+ if (!keep.has(name)) delete env[name];
147
+ }
148
+ return env;
149
+ }
150
+ function unstoredSlugs(requested, stored) {
151
+ const canonical = [...new Set(requested.map((slug) => slug.trim().toLowerCase()).filter((slug) => slug !== ""))];
152
+ return canonical.filter((slug) => !stored.has(slug));
153
+ }
154
+ function toEnv(stored) {
155
+ const env = {};
156
+ for (const [slug, value] of stored) {
157
+ const name = envVarFor(slug);
158
+ if (name !== void 0) env[name] = value;
159
+ }
160
+ return env;
161
+ }
162
+ function childEnv(base, fetched, override) {
163
+ const env = {};
164
+ for (const [name, value] of Object.entries(base)) {
165
+ if (value !== void 0) env[name] = value;
166
+ }
167
+ for (const [name, value] of Object.entries(fetched)) {
168
+ const existing = env[name];
169
+ if (override || existing === void 0 || existing.trim() === "") env[name] = value;
170
+ }
171
+ return env;
172
+ }
173
+ function exitCodeFor(code, signal) {
174
+ if (code !== null) return code;
175
+ if (signal !== null) {
176
+ const number = constants.signals[signal];
177
+ if (typeof number === "number") return 128 + number;
178
+ }
179
+ return 1;
180
+ }
181
+ async function list2(ctx) {
182
+ const stored = await fetchKeys(ctx);
183
+ const providers = [...stored.keys()].sort().map((provider) => ({ provider, env: envVarFor(provider) ?? null }));
184
+ const human = providers.map(({ provider, env }) => `${clean(provider)} ${env ?? "(no env var)"}
185
+ `).join("");
186
+ return { json: { providers }, human };
187
+ }
188
+ var FORWARDED_SIGNALS = ["SIGTERM"];
189
+ var HELD_SIGNALS = ["SIGINT"];
190
+ async function exec(ctx, opts) {
191
+ const [program2, ...args] = opts.command;
192
+ if (program2 === void 0 || program2 === "") {
193
+ throw new UsageError("keys exec: name the command to run after `--`, e.g. `harness keys exec -- npm test`");
194
+ }
195
+ if (process.platform === "win32") {
196
+ throw new LocalError("keys exec is available on macOS and Linux only");
197
+ }
198
+ const onlyGiven = opts.only !== void 0 && opts.only.length > 0;
199
+ if (onlyGiven && normalizeSlugs(opts.only ?? []).length === 0) {
200
+ throw new UsageError(`keys exec: --only needs at least one provider slug \u2014 known: ${[...KEY_ENV_VARS.keys()].join(", ")}`);
201
+ }
202
+ const unknown = unknownSlugs(opts.only ?? []);
203
+ if (unknown.length > 0) {
204
+ throw new UsageError(
205
+ `keys exec: unknown provider slug ${unknown.map(clean).join(", ")} \u2014 known: ${[...KEY_ENV_VARS.keys()].join(", ")}`
206
+ );
207
+ }
208
+ const stored = await fetchKeys(ctx);
209
+ const requested = normalizeSlugs(opts.only ?? []);
210
+ const narrowed = requested.length > 0;
211
+ if (narrowed) {
212
+ const missing = unstoredSlugs(requested, stored);
213
+ if (missing.length > 0) {
214
+ process.stderr.write(
215
+ `harness: no key stored for ${missing.join(", ")} \u2014 the command gets your shell's own variable for those, if any
216
+ `
217
+ );
218
+ }
219
+ }
220
+ const env = childEnv(
221
+ narrowed ? withholdOthers(process.env, requested) : process.env,
222
+ toEnv(selectSlugs(stored, requested)),
223
+ opts.override
224
+ );
225
+ const name = clean(program2);
226
+ const exitCode = await new Promise((resolve, reject) => {
227
+ let child;
228
+ try {
229
+ child = spawn(program2, args, { stdio: "inherit", env });
230
+ } catch (error) {
231
+ const code = error.code ?? "invalid arguments";
232
+ reject(new CommandError(`cannot start ${name}: ${code}`));
233
+ return;
234
+ }
235
+ const handlers = [];
236
+ for (const signal of FORWARDED_SIGNALS) {
237
+ const handler = () => {
238
+ child.kill(signal);
239
+ };
240
+ process.on(signal, handler);
241
+ handlers.push([signal, handler]);
242
+ }
243
+ for (const signal of HELD_SIGNALS) {
244
+ const handler = () => {
245
+ };
246
+ process.on(signal, handler);
247
+ handlers.push([signal, handler]);
248
+ }
249
+ let settled = false;
250
+ let spawned = false;
251
+ const settle = (outcome) => {
252
+ if (settled) return;
253
+ settled = true;
254
+ for (const [signal, handler] of handlers) process.off(signal, handler);
255
+ outcome();
256
+ };
257
+ child.once("spawn", () => {
258
+ spawned = true;
259
+ });
260
+ child.on("error", (error) => {
261
+ settle(() => {
262
+ const code = error.code ?? error.message;
263
+ reject(new CommandError(spawned ? `${name} failed: ${code}` : `cannot run ${name}: ${code}`));
264
+ });
265
+ });
266
+ child.on("exit", (code, signal) => {
267
+ settle(() => resolve(exitCodeFor(code, signal)));
268
+ });
269
+ });
270
+ return { json: { exit_code: exitCode }, human: "", exitCode };
271
+ }
272
+
91
273
  // src/core/auth.ts
92
274
  import { createHash, randomBytes } from "crypto";
93
275
  import { execFile } from "child_process";
@@ -744,10 +926,10 @@ function guardBrokenPipes(streams = [process.stdout, process.stderr]) {
744
926
  }
745
927
 
746
928
  // src/core/upgrade.ts
747
- import { spawn, spawnSync } from "child_process";
929
+ import { spawn as spawn2, spawnSync } from "child_process";
748
930
  import {
749
931
  accessSync as accessSync2,
750
- constants,
932
+ constants as constants2,
751
933
  existsSync,
752
934
  linkSync,
753
935
  mkdirSync,
@@ -925,7 +1107,7 @@ function spawnRefresh() {
925
1107
  const self = process.argv[1];
926
1108
  if (self === void 0 || self === "") return;
927
1109
  try {
928
- const child = spawn(process.execPath, [self, UPDATE_CHECK_COMMAND], {
1110
+ const child = spawn2(process.execPath, [self, UPDATE_CHECK_COMMAND], {
929
1111
  detached: true,
930
1112
  stdio: "ignore"
931
1113
  });
@@ -1046,7 +1228,7 @@ function npmInstallGlobal(install, stillHeld) {
1046
1228
  };
1047
1229
  }
1048
1230
  try {
1049
- accessSync2(globalRoot, constants.W_OK);
1231
+ accessSync2(globalRoot, constants2.W_OK);
1050
1232
  } catch {
1051
1233
  return {
1052
1234
  ok: false,
@@ -1328,7 +1510,7 @@ async function execute(config, jsonFlag, mutating, run) {
1328
1510
  process.stderr.write(hostNotice);
1329
1511
  if (output.notice !== void 0) process.stderr.write(output.notice);
1330
1512
  process.stdout.write(jsonFlag ? json(output.json) : output.human);
1331
- return exitWith(output.partial === true ? 1 : 0);
1513
+ return exitWith(output.exitCode ?? (output.partial === true ? 1 : 0));
1332
1514
  } catch (error) {
1333
1515
  return fail(error, hostNotice);
1334
1516
  }
@@ -1901,19 +2083,38 @@ user.command("list").option("--json", "emit JSON").action(async (opts) => {
1901
2083
  (ctx) => userList(ctx)
1902
2084
  );
1903
2085
  });
1904
- program.command("upload").description("upload a file; prints markdown to reference it").argument("[path]").option("--json", "emit JSON").action(async (path, opts) => {
2086
+ program.command("upload").description("upload an image (25 MB) or H.264 MP4 video (100 MB, optional AAC audio); prints markdown").argument("[path]").option("--json", "emit JSON").action(async (path, opts) => {
1905
2087
  await withConfig(opts.json === true, true, (ctx) => {
1906
2088
  if (path === void 0)
1907
2089
  throw new UsageError(
1908
- "usage: harness upload <path> (png/jpg/webp/gif, up to 25 MB)"
2090
+ "usage: harness upload <path> (png/jpg/webp/gif up to 25 MB; H.264 MP4 with optional AAC audio up to 100 MB)"
1909
2091
  );
1910
2092
  return upload(ctx, path);
1911
2093
  });
1912
2094
  });
1913
2095
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
1914
- const { startMcpServer } = await import("./server-QVYSED3Z.js");
2096
+ const { startMcpServer } = await import("./server-5YNUXJO2.js");
1915
2097
  await startMcpServer();
1916
2098
  });
2099
+ var keys = program.command("keys").description("the org's provider API keys, handed to tooling without printing them").enablePositionalOptions();
2100
+ keys.command("list").description("provider slugs the org has keys for and the env var each maps to \u2014 never the values").option("--json", "emit JSON").action(async (opts) => {
2101
+ await withConfig(opts.json === true, false, (ctx) => list2(ctx));
2102
+ });
2103
+ keys.command("exec").description(
2104
+ "run a command with the org's keys in its environment and exit with its exit code: harness keys exec [--override] -- <command> [args...]"
2105
+ ).argument("[command...]", "the command and its arguments").option("--override", "let fetched keys replace variables already set in this shell").option(
2106
+ "--only <slugs>",
2107
+ "pass only these provider slugs (comma-separated, repeatable); default: everything stored",
2108
+ collect,
2109
+ []
2110
+ ).option("--json", "accepted for uniformity; stdout belongs to the command, so it changes nothing").passThroughOptions().action(async (command, opts) => {
2111
+ const only = opts.only.flatMap((value) => value.split(","));
2112
+ await withConfig(
2113
+ false,
2114
+ false,
2115
+ (ctx) => exec(ctx, { command, override: opts.override === true, only })
2116
+ );
2117
+ });
1917
2118
  program.command("login").description("sign in via the browser (infra.one session)").option("--url <url>", "host to sign in to").option("--no-browser", "print the URL and prompt for a pasted token instead").option(
1918
2119
  "--timeout <ms>",
1919
2120
  "how long to wait for the browser callback",
@@ -61,7 +61,7 @@ import {
61
61
  upload,
62
62
  userList,
63
63
  workflowStates
64
- } from "./chunk-KKAAKRKF.js";
64
+ } from "./chunk-WY6F3IJB.js";
65
65
 
66
66
  // src/mcp/server.ts
67
67
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -659,7 +659,7 @@ var TOOLS = [
659
659
  // ── Uploads ──
660
660
  {
661
661
  name: "upload",
662
- description: "Upload an image file (png/jpg/webp/gif, \u2264 25 MB) from a local path; returns markdown to reference it in issue fields or comments.",
662
+ description: "Upload a local image (png/jpg/webp/gif, up to 25 MB) or H.264 MP4 video (8-bit 4:2:0, optional AAC audio and mov_text subtitles, up to 100 MB). Returns metadata and markdown: images use image markdown; videos use ordinary links rendered as inline players in Harness issues. A video is private to its uploader until that user saves its link in an issue description or comment. Reading an issue does not automatically download videos.",
663
663
  readOnly: false,
664
664
  shape: { path: z.string().describe("local file path") },
665
665
  handler: (ctx, args) => upload(ctx, args["path"])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allocator-one/harness-cli",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "CLI and MCP server for Harness, allocator.one's issue tracker",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/spec/openapi.json CHANGED
@@ -425,7 +425,7 @@
425
425
  "get": {
426
426
  "callbacks": {},
427
427
  "deprecated": false,
428
- "description": "Requires the `harness:read` scope. Streams the attachment bytes (binary response).",
428
+ "description": "Requires the `harness:read` scope. Streams attachment bytes. Videos recheck the owning organization's and issue's access on every request and use private, no-store caching. Supports one bytes Range: bounded, open-ended, or suffix; returns 206 with Content-Range, or 416 for an unsatisfiable range. Malformed, multipart, and conditional ranges fall back to a full 200. Images retain full-response behavior. Add ?mode=download for attachment disposition.",
429
429
  "parameters": [
430
430
  {
431
431
  "description": "Attachment UUID (the directory segment of the upload's URL)",
@@ -463,6 +463,12 @@
463
463
  "404": {
464
464
  "description": "Not found"
465
465
  },
466
+ "206": {
467
+ "description": "Requested video byte range, with Content-Range and Content-Length"
468
+ },
469
+ "416": {
470
+ "description": "Unsatisfiable range; Content-Range contains the actual file size"
471
+ },
466
472
  "422": {
467
473
  "description": "Validation error"
468
474
  }
@@ -791,7 +797,7 @@
791
797
  "post": {
792
798
  "callbacks": {},
793
799
  "deprecated": false,
794
- "description": "Requires the `harness:write` scope. Multipart upload of a png, jpg, webp, or gif (max 25 MB). Returns the attachment URL and a ready-to-paste `![alt](url)` markdown snippet for issue descriptions and comments. Attachments are private: the URL serves admins with a session; API clients fetch the bytes via `GET /api/v1/attachments/{attachment_id}/{file}`.",
800
+ "description": "Requires the `harness:write` scope. Multipart upload of a png, jpg, webp, or gif (25 MB), or H.264 MP4 video (100 MB; 8-bit 4:2:0 video, optional AAC audio and mov_text subtitles). No transcoding. Returns data.url, filename, content_type, byte_size, and markdown. Images use `![alt](url)`; videos use ordinary `[filename](url)` links. Supply an optional issue identifier to bind the video to that issue's access policy. Without it, the video is private to its uploader until that user saves its link in an issue description or comment. Copying a bound video link does not change its owning issue. Browser URLs require a session; API clients use GET /api/v1/attachments/{attachment_id}/{file} with a read-scoped token bound to the owning organization. Invalid media answers 422; an inaccessible issue answers 404. Existing image access remains unchanged.",
795
801
  "parameters": [],
796
802
  "requestBody": {
797
803
  "content": {
@@ -799,9 +805,13 @@
799
805
  "schema": {
800
806
  "properties": {
801
807
  "file": {
802
- "description": "png, jpg, webp, or gif up to 25 MB",
808
+ "description": "png, jpg, webp, or gif up to 25 MB; H.264 MP4 up to 100 MB",
803
809
  "format": "binary",
804
810
  "type": "string"
811
+ },
812
+ "issue": {
813
+ "description": "Optional owning issue identifier, for example IO-123. Must be accessible in the token's organization.",
814
+ "type": "string"
805
815
  }
806
816
  },
807
817
  "required": [
@@ -836,7 +846,7 @@
836
846
  "description": "File storage failed (transient) — retry"
837
847
  }
838
848
  },
839
- "summary": "Upload an image attachment",
849
+ "summary": "Upload an image or video attachment",
840
850
  "tags": []
841
851
  }
842
852
  },