Compare commits

..

11 Commits

Author SHA1 Message Date
nntrivi2001 c3d88c0685 chore: v2.8.49 — CodeCommit clone works across regions; Clone runs in the background 2026-11-02 10:00:09 +07:00
nntrivi2001 e4ecd116a2 fix(clone): a CodeCommit region borrows saved key IDs; Clone runs in the background
An IAM SSH key ID logs in to every region of its AWS account, but a login
was saved per host, so a repo in a region with no key ID of its own was
refused. Clone now tries the key IDs saved for the other CodeCommit regions
(each with its own key; the first ls-remote accepts, or the only one) and
keeps that user in the clone's origin.

Files → New → Clone repository steps aside for its progress chip as soon
as the clone starts; a failure asking for a login reopens the form.
2026-11-02 10:00:09 +07:00
nntrivi2001 8d0477e0f7 chore: v2.8.48 — a folder upload skips .git with one notice instead of an error per file 2026-11-02 09:53:03 +07:00
nntrivi2001 e7e9cb20d8 fix(files): a folder upload leaves .git out with one notice
Uploading a cloned repo from the desktop sent every .git object, and the
server refused each one (nothing is uploaded into .git) — one error toast
per object, thousands of them. The drop walker no longer reads a .git
folder, and uploadAll drops any path inside .git before sending, saying
once how many were skipped.
2026-11-02 09:53:03 +07:00
nntrivi2001 ebfae9b891 chore: v2.8.47 — a Codex ChatGPT pool account reaches its pane even while an older login's daemon runs 2026-10-30 16:06:33 +07:00
nntrivi2001 3ab484ddc0 fix(pool): a Codex ChatGPT pool pane runs on the login placed for it
Codex >= 0.164 hands a plain TUI (no -c override) to the CODEX_HOME's
shared app-server daemon, which outlives every TUI and keeps the login it
started with. A ChatGPT pool account written into a member's auth.json
therefore reached no pane while their daemon still ran on the previous
login (reproduced on 0.164.0: the daemon kept sending the old key, a
--no-daemon TUI sent the new one). Such a pane now runs `codex
--no-daemon`, only when that binary's --help lists the flag (probed once
per path/size/mtime), so an older Codex still starts.
2026-10-30 16:06:20 +07:00
nntrivi2001 6bcef26446 chore: v2.8.46 — last updated time and installers' versions in Artifacts; a web app's pages are no longer auto-captured 2026-10-30 15:23:03 +07:00
nntrivi2001 240444eafd fix(artifacts): auto-capture leaves a web app's own HTML pages alone
At a session's Stop every untracked .html was snapshotted as a report, so a
web project not yet committed had its index.html taken into its owner's
Artifacts. An index.html, a page under a site folder (www, public, dist,
build, src, static, templates, assets, site, _site) or one beside scripts or
stylesheets (outside the workspace root) is now a site's own page and left
alone; a standalone HTML report is still captured.
2026-10-30 15:22:19 +07:00
nntrivi2001 d09c9d6633 feat(artifacts): last updated time on skills and files; each installer's copy marked
A row said nothing about when its content last changed, and the install list
could not tell who still runs an old version. `artifacts.version_at` (new
column, existing rows from `updated_at`) is stamped only when a snapshot is
replaced — a rename or share toggle leaves it — and served as `versionAt`:
skill and file rows read "Updated 3h ago", the detail sheet "Last updated".
`GET /api/artifacts/:id/installs` gives each skill install a `state` —
latest / outdated / removed / unknown — from its Claude skill copies' hash
against the newest snapshot, shown as a badge.
2026-10-30 15:21:31 +07:00
nntrivi2001 bc44cbc8b7 chore: v2.8.45 — Share again updates a shared skill; a same-named upload updates it too 2026-10-30 14:20:51 +07:00
nntrivi2001 e7060a5e42 feat(artifacts): Share again takes a skill's newest version; a same-named upload updates it
A shared skill followed its owner's ~/.claude/skills folder only, so one
uploaded from a browser (or living in a plugin) stayed at its first snapshot
for good. Share again (a skill's ⋯ menu and detail sheet) now takes the
owner's copy on the server — its source folder, else the same-named skill of
a plugin they installed at user scope — and shares it; with no copy it asks
for the folder and uploads it onto that row (`id`). An upload whose skill is
named like one the uploader already has replaces that row's version instead
of adding a twin. Installers are then offered Update, as before.
2026-10-30 14:20:18 +07:00
26 changed files with 879 additions and 65 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.44 (2026-10-30) — `discard` takes the Changes panel's ticked files and blocks (`hunks`), never every file._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.8.45 (2026-10-30) — Artifacts **Share again** retakes a skill's newest version; a same-named upload updates the row._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -60,7 +60,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin; a batch upload of many small files is ONE `extract` of a tar the server writes itself — folders and regular files only, names it checked, `tar -k`) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `<data dir>/trash/<user id>/`, items tagged `store`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
- **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo.
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins; **Share again** (`POST /api/artifacts/:id/reshare`) takes the owner's folder on the server — else a user-scope plugin's same-named skill — on a click, and an upload named like the uploader's own skill, or carrying `id`, replaces that row's snapshot instead of adding a twin) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written at every spawn as the ONLY file `CLAUDE.md` of `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. Billing has one more owner: a session NO workspace owns belongs to the user of the Linux account it ran as (`sessions.account` — the hook's peer, else its folder; `server/lib/session-account.js`, the host's account → the admin marked `users.host_account`, else the only admin), so a script's `claude -p` counts in Today, the Users page and spend quotas — a workspace's ownership always wins over it.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
@@ -7,10 +7,11 @@
* folder's account with that account's own SSH key / HTTPS login — and opens
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
* A running clone shows `CloneProgress` and never holds the page: closing the
* dialog only hides it behind a small progress chip (click to reopen), the
* A clone runs in the background from the moment it starts: the dialog steps
* aside for a small progress chip (`CloneProgress`; click to reopen), the
* request keeps going, and its outcome arrives as a toast — a failure is kept
* for the next open so a login row is still there to fill in.
* for the next open, and one asking for a login reopens the dialog with its
* login row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -111,6 +112,9 @@ export function CloneHereDialog({
setTarget(into);
setError(null);
setSshRefused(false);
// Runs in the background by default: the dialog steps aside for the
// progress chip, and comes back only if the clone needs a login.
onClose();
try {
const result = await api.lanes.clone({
url: u,
@@ -156,6 +160,8 @@ export function CloneHereDialog({
if (!openRef.current) {
keepRef.current = true;
toast.error(message);
// A login to type: show the form again with its login row.
if (e.code === "ECREDENTIALS") onReopen();
}
}
};
+9 -1
View File
@@ -229,6 +229,8 @@ async function walkEntry(
out.push({ file, rel: `${prefix}${entry.name}` });
return;
}
// The server takes nothing into .git: never read one off the disk at all.
if (entry.name === ".git") return;
const reader = (entry as FileSystemDirectoryEntry).createReader();
// readEntries answers in pages until it answers [].
for (;;) {
@@ -614,8 +616,14 @@ export function FileBrowser({
};
/** Upload `files` (each with its path inside the pick/drop) under `dir`. */
const uploadAll = async (files: { file: File; rel: string }[], dir = path) => {
const uploadAll = async (picked: { file: File; rel: string }[], dir = path) => {
setMenu(null);
// A dropped/picked repo carries its .git, which the server refuses file by
// file — skip it here with ONE notice instead of a toast per object.
const files = picked.filter((f) => !f.rel.split("/").includes(".git"));
if (files.length < picked.length) {
toast.info(t("fx.gitSkipped", { count: picked.length - files.length }));
}
if (!files.length) return;
const bytes = files.reduce((n, f) => n + f.file.size, 0);
if (bytes > FX_BATCH_MAX_BYTES) {
@@ -3,7 +3,8 @@
* opens sub-folders, makes a folder inline, deletes into the Trash without a
* confirm, copies in one workspace and pastes into another (`fromLane`),
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
* batch over 500 MB before sending anything, restores from the user's own
* batch over 500 MB before sending anything, leaves a repo's .git out of a
* folder upload with one notice, restores from the user's own
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
* one saying why), opens each user's Trash from its folder (asking before a
* purge there, sending the item's store) and
@@ -269,6 +270,23 @@ describe("FileExplorer", () => {
expect(api.files.uploadBatch).not.toHaveBeenCalled();
});
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
open();
await screen.findByText("a.txt");
const mk = (rel: string) => {
const f = new File(["hi"], rel.split("/").pop()!);
Object.defineProperty(f, "webkitRelativePath", { value: rel });
return f;
};
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.files.upload).mock.calls[0]![1]).toBe("repo/src/a.js");
expect(toast.info).toHaveBeenCalledTimes(1);
expect(toast.error).not.toHaveBeenCalled();
});
it("refuses a batch over 500 MB before sending anything", async () => {
open();
await screen.findByText("a.txt");
@@ -614,14 +632,16 @@ describe("FileExplorer — New → Clone repository", () => {
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
expect(await within(dialog).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
await user.click(within(dialog).getByRole("button", { name: "Run in background" }));
// In the background straight away: the chip carries the progress.
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
const chip = await screen.findByTestId("fx-clone-chip");
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
expect(await within(chip).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
await user.click(chip);
expect(screen.getByTestId("fx-clone-dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
const back = screen.getByTestId("fx-clone-dialog");
expect(await within(back).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
await user.click(within(back).getByRole("button", { name: "Run in background" }));
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
finish({
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
created: true,
@@ -631,7 +651,7 @@ describe("FileExplorer — New → Clone repository", () => {
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
});
it("toasts a failure that lands while hidden and keeps it for the next open", async () => {
it("toasts a failure that lands while hidden, and reopens the form for a login", async () => {
let fail: (e: unknown) => void = () => {};
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
@@ -641,12 +661,12 @@ describe("FileExplorer — New → Clone repository", () => {
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await user.keyboard("{Escape}");
fail(
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
);
await waitFor(() => expect(toast.error).toHaveBeenCalled());
const again = await openClone(user);
// A login to type reopens the form by itself, its row ready.
const again = await screen.findByTestId("fx-clone-dialog");
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
"https://git.example/o/r.git"
@@ -669,10 +689,11 @@ describe("FileExplorer — New → Clone repository", () => {
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
const login = await within(dialog).findByTestId("fx-clone-login");
const reopened = await screen.findByTestId("fx-clone-dialog");
const login = await within(reopened).findByTestId("fx-clone-login");
await user.type(within(login).getByLabelText("Username"), "ann");
await user.type(within(login).getByLabelText("Password or access token"), "tok");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await user.click(within(reopened).getByTestId("fx-clone-submit"));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenLastCalledWith(
expect.objectContaining({
+12 -1
View File
@@ -28,6 +28,12 @@
"unshare": "Unshare",
"shareDone": "Shared to the Hub",
"unshareDone": "Removed from the Hub",
"reshare": "Share again",
"reshareDone": "New version shared — people who installed it are offered Update",
"reshareSame": "Already the newest version — shared",
"reshareAskTitle": "Choose the {{name}} folder again",
"reshareAskBody": "This server has no copy of {{name}}. Choose the skill's folder (the one holding SKILL.md): it replaces this version, and people who installed it are offered Update. For a .zip, upload it through Add skill → Upload: a skill with the same name updates your existing one.",
"resharePick": "Choose folder",
"open": "Open",
"copyLink": "Copy link",
"preview": "Preview",
@@ -136,6 +142,11 @@
"installsYou": "You",
"installsUnknown": "A removed user",
"installsReplaced": "replaced an older copy",
"versionAt": "Updated {{ago}}",
"versionAtFull": "Last updated {{date}}",
"installState.latest": "Latest",
"installState.outdated": "Old version",
"installState.removed": "Removed",
"installsPluginWhere": "claude plugin install --scope user",
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
@@ -179,7 +190,7 @@
"stat.installs": "Installs",
"stat.files": "Files",
"stat.size": "Size",
"stat.updated": "Updated",
"stat.updated": "Last updated",
"detail.runAs": "Run it as",
"detail.market": "Marketplace",
"detail.added": "Added {{date}}",
+1
View File
@@ -590,6 +590,7 @@
"fx.dropHint": "Drag files or folders here to upload · drag onto a folder to move (Ctrl: copy)",
"fx.uploading": "Uploading {{done}}/{{total}}…",
"fx.uploaded": "Uploaded {{count}} file(s).",
"fx.gitSkipped": "Skipped {{count}} file(s) inside .git — nothing is uploaded into .git.",
"fx.uploadFailed": "Could not upload {{name}}: {{message}}",
"fx.tooBig": "{{size}} is more than one upload may carry ({{max}}). Upload fewer files at a time.",
"fx.trashed": "Moved {{count}} item(s) to the Trash.",
+12 -1
View File
@@ -28,6 +28,12 @@
"unshare": "Bỏ chia sẻ",
"shareDone": "Đã chia sẻ lên Hub",
"unshareDone": "Đã gỡ khỏi Hub",
"reshare": "Share lại",
"reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật",
"reshareSame": "Đã là bản mới nhất — đã share",
"reshareAskTitle": "Chọn lại thư mục {{name}}",
"reshareAskBody": "Server không có bản của {{name}}. Chọn thư mục của skill (thư mục chứa SKILL.md): bản này sẽ được thay, và người đã cài sẽ thấy nút Cập nhật. Với file .zip, tải lên qua Thêm skill → Tải lên: skill trùng tên sẽ cập nhật mục bạn đã có.",
"resharePick": "Chọn thư mục",
"open": "Mở",
"copyLink": "Sao chép link",
"preview": "Xem",
@@ -137,6 +143,11 @@
"installsYou": "Bạn",
"installsUnknown": "Người dùng đã xoá",
"installsReplaced": "thay bản cũ",
"versionAt": "Cập nhật {{ago}}",
"versionAtFull": "Cập nhật lần cuối {{date}}",
"installState.latest": "Bản mới nhất",
"installState.outdated": "Bản cũ",
"installState.removed": "Đã gỡ",
"installsPluginWhere": "claude plugin install --scope user",
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
@@ -180,7 +191,7 @@
"stat.installs": "Lượt cài",
"stat.files": "File",
"stat.size": "Dung lượng",
"stat.updated": "Cập nhật",
"stat.updated": "Cập nhật lần cuối",
"detail.runAs": "Gọi bằng",
"detail.market": "Marketplace",
"detail.added": "Thêm lúc {{date}}",
+1
View File
@@ -590,6 +590,7 @@
"fx.dropHint": "Kéo file hoặc thư mục vào đây để tải lên · kéo vào một thư mục để di chuyển (Ctrl: sao chép)",
"fx.uploading": "Đang tải lên {{done}}/{{total}}…",
"fx.uploaded": "Đã tải lên {{count}} file.",
"fx.gitSkipped": "Đã bỏ qua {{count}} file trong .git — không tải gì vào .git.",
"fx.uploadFailed": "Không tải lên được {{name}}: {{message}}",
"fx.tooBig": "{{size}} vượt quá giới hạn một lần tải lên ({{max}}). Hãy tải ít file hơn mỗi lần.",
"fx.trashed": "Đã chuyển {{count}} mục vào Thùng rác.",
+23 -6
View File
@@ -3324,16 +3324,17 @@ export const api = {
* several skills at once (one artifact each). `path` keeps a folder's layout. */
uploadSkills: (
files: { file: File; path: string }[],
fields: { name?: string; title?: string; description?: string } = {}
/** `id`: Share again — the upload replaces that skill row's version. */
fields: { name?: string; title?: string; description?: string; id?: string } = {}
) => {
const form = new FormData();
for (const f of files) form.append("files", f.file, f.file.name);
form.append("paths", JSON.stringify(files.map((f) => f.path)));
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ items: Artifact[] }>("/artifacts/skill/upload", {
method: "POST",
body: form,
});
return request<{ items: (Artifact & { replaced?: boolean; changed?: boolean })[] }>(
"/artifacts/skill/upload",
{ method: "POST", body: form }
);
},
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
@@ -3372,6 +3373,12 @@ export const api = {
method: "PATCH",
body: JSON.stringify(body),
}),
/** POST /api/artifacts/:id/reshare — Share again: the owner's skill folder on
* the server becomes the new version (409 ENOSOURCE when it has none). */
reshare: (id: number) =>
request<{ item: Artifact; changed: boolean }>(`/artifacts/${id}/reshare`, {
method: "POST",
}),
/** `kept`: a shared one left your library but stays in the Hub. */
remove: (id: number) =>
request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }),
@@ -4713,11 +4720,21 @@ export interface Artifact {
suggestedForMe?: boolean;
createdAt: string;
updatedAt: string;
/** When its content last changed (a new snapshot); a page or plugin: when it was added. */
versionAt: string;
}
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
export interface ArtifactInstalls {
items: { user: string | null; mine: boolean; at: string; replaced: boolean; paths?: string[] }[];
items: {
user: string | null;
mine: boolean;
at: string;
replaced: boolean;
/** Skills: where that install's copy stands against the newest version now. */
state?: "latest" | "outdated" | "removed" | "unknown";
paths?: string[];
}[];
/** Installs counted before the list was kept. */
unrecorded: number;
}
+111 -4
View File
@@ -28,9 +28,14 @@
* **Installed** instead of the Install button; **Uninstall** (a skill's
* copies moved aside, a plugin `claude plugin uninstall`) is offered only in
* the card's ⋯ menu. A skill follows its owner's folder (the server takes a
* change there as a new snapshot when the Hub is listed); an installed copy
* change there as a new snapshot when the Hub is listed; **Share again**
* takes its folder on the server — or a plugin's copy — on a click, else
* asks for the folder and uploads it onto the same row); an installed copy
* that differs from the newest version shows **Update** (a pack: "Update N"),
* which replaces it after a confirmation, the old copy moved aside. Editing a skill
* which replaces it after a confirmation, the old copy moved aside. A skill or
* file row says when its CONTENT last changed ("Updated 3h ago", `versionAt`),
* and the install count's list marks each installer's copy Latest / Old
* version / Removed. Editing a skill
* edits its SKILL.md (name, description, instructions — a new snapshot);
* its preview opens with what it installs as and when Claude uses it. Deleting a shared
* row of your own only takes it out of your library — it stays in the Hub
@@ -79,6 +84,7 @@ import { toast } from "../lib/toast";
import { confirmDialog } from "../lib/confirm";
import { useDialog } from "../hooks/useDialog";
import { copyText } from "../lib/clipboard";
import { timeAgo } from "../lib/format";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import {
FileUploadPanel,
@@ -277,6 +283,53 @@ export function Artifacts() {
}
};
// Share again: the server's copy of the skill, else the folder picked again
// (uploaded onto the same row). Either way installers are offered Update.
const reshareInput = useRef<HTMLInputElement>(null);
const reshareRow = useRef<Artifact | null>(null);
useEffect(() => {
reshareInput.current?.setAttribute("webkitdirectory", "");
}, []);
const reshared = (changed: boolean) => {
toast.success(t(changed ? "reshareDone" : "reshareSame"));
load();
};
const reshare = async (a: Artifact) => {
try {
const r = await api.artifacts.reshare(a.id);
reshared(r.changed);
} catch (err) {
if ((err as { code?: string }).code !== "ENOSOURCE") return toast.error(err);
if (
!(await confirmDialog({
title: t("reshareAskTitle", { name: a.source ?? a.title }),
message: t("reshareAskBody", { name: a.source ?? a.title }),
confirmLabel: t("resharePick"),
}))
)
return;
reshareRow.current = a;
reshareInput.current?.click();
}
};
const reshareUpload = async (list: FileList | null) => {
const a = reshareRow.current;
reshareRow.current = null;
if (!a || !list?.length) return;
try {
const r = await api.artifacts.uploadSkills(
Array.from(list).map((file) => ({
file,
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
})),
{ id: String(a.id) }
);
reshared(r.items.some((i) => i.changed !== false));
} catch (err) {
toast.error(err);
}
};
const [renaming, setRenaming] = useState<Artifact | null>(null);
const remove = async (a: Artifact) => {
@@ -442,6 +495,7 @@ export function Artifacts() {
const actionsFor = (a: Artifact): CardActions => ({
onOpen: () => setDetailId(a.id),
onShare: () => void share(a),
onReshare: () => void reshare(a),
onRename: () => {
setDetailId(null);
setRenaming(a);
@@ -572,6 +626,17 @@ export function Artifacts() {
return (
<div className="animate-fade-in space-y-5" data-testid="artifacts-page">
<input
ref={reshareInput}
type="file"
multiple
hidden
data-testid="artifact-reshare-folder"
onChange={(e) => {
void reshareUpload(e.target.files);
e.target.value = "";
}}
/>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
@@ -1066,6 +1131,8 @@ function EmptyState({
type CardActions = {
onOpen: () => void;
onShare: () => void;
/** A skill of your own: its newest version, shared (installers get Update). */
onReshare: () => void;
onRename: () => void;
onDelete: () => void;
onInstall: () => void;
@@ -1208,6 +1275,7 @@ function Meta({
server: boolean;
date?: boolean;
}) {
const { t } = useTranslation("artifacts");
const parts: React.ReactNode[] = [];
if (server && a.owner && !a.mine)
parts.push(
@@ -1215,7 +1283,18 @@ function Meta({
{a.owner}
</span>
);
if (date)
// A skill or file is a snapshot that changes: when it last did. Anything else: when added.
if (a.kind === "skill" || a.kind === "file")
parts.push(
<span
key="at"
className="shrink-0"
title={t("versionAtFull", { date: new Date(a.versionAt).toLocaleString() })}
>
{t("versionAt", { ago: timeAgo(a.versionAt) })}
</span>
);
else if (date)
parts.push(
<span key="at" className="shrink-0" title={new Date(a.createdAt).toLocaleString()}>
{fmtDate(a.createdAt)}
@@ -1237,6 +1316,7 @@ function ArtifactMenu({
menu,
onOpen,
onShare,
onReshare,
onRename,
onDelete,
onInstall,
@@ -1297,6 +1377,9 @@ function ArtifactMenu({
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
</MenuItem>
)}
{server && a.kind === "skill" && (
<MenuItem onSelect={pick(onReshare)}>{t("reshare")}</MenuItem>
)}
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
{!hubOnly && (
<MenuItem danger onSelect={pick(onDelete)}>
@@ -1447,6 +1530,7 @@ function DetailDialog({
canInstall,
onClose,
onShare,
onReshare,
onRename,
onDelete,
onInstall,
@@ -1468,7 +1552,10 @@ function DetailDialog({
: []),
...(a.files.length ? [{ label: t("stat.files"), value: String(a.files.length) }] : []),
...(a.size ? [{ label: t("stat.size"), value: fmtBytes(a.size) }] : []),
{ label: t("stat.updated"), value: fmtDate(a.updatedAt) },
{
label: t("stat.updated"),
value: new Date(a.versionAt).toLocaleString(),
},
];
return (
<Modal title={a.title} onClose={onClose} size="md">
@@ -1611,6 +1698,11 @@ function DetailDialog({
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
</button>
)}
{server && a.kind === "skill" && (
<button type="button" className={secondary} onClick={onReshare}>
{t("reshare")}
</button>
)}
<button type="button" className={secondary} onClick={onRename}>
{t("rename")}
</button>
@@ -1634,6 +1726,13 @@ function DetailDialog({
* The list floats over the page (a portal at the button, below it or above
* when there is no room, kept inside the window): inside the card list's
* `overflow-hidden` it was cut off at the last card. */
/** An installer's copy against the newest version: green, amber, grey. */
const STATE_CLASS = {
latest: "bg-status-success/15 text-status-success",
outdated: "bg-status-warning/15 text-status-warning",
removed: "bg-surface-2 text-fg-muted",
} as const;
function InstallsButton({ item }: { item: Artifact }) {
const { t } = useTranslation("artifacts");
const [open, setOpen] = useState(false);
@@ -1725,6 +1824,14 @@ function InstallsButton({ item }: { item: Artifact }) {
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
)}
</span>
{i.state && i.state !== "unknown" && (
<span
data-testid="install-state"
className={`shrink-0 rounded px-1 text-[10px] ${STATE_CLASS[i.state]}`}
>
{t(`installState.${i.state}`)}
</span>
)}
<span className="shrink-0 text-[11px] text-fg-muted">
{new Date(i.at).toLocaleString()}
</span>
+75 -1
View File
@@ -46,6 +46,7 @@ vi.mock("../../lib/api", () => ({
addPlugin: vi.fn(),
inspectMarket: vi.fn(),
uploadSkills: vi.fn(),
reshare: vi.fn(),
writeSkill: vi.fn(),
uploadFile: vi.fn(),
candidates: vi.fn(),
@@ -76,6 +77,7 @@ const item = (over: Partial<Artifact>): Artifact => ({
installs: 0,
createdAt: "2026-10-14T00:00:00Z",
updatedAt: "2026-10-14T00:00:00Z",
versionAt: "2026-10-14T00:00:00Z",
...over,
});
@@ -348,9 +350,16 @@ describe("Artifacts", () => {
mine: true,
at: "2026-10-19T10:00:00Z",
replaced: false,
state: "latest",
paths: ["/h/.claude/skills/deploy"],
},
{ user: "khoa", mine: false, at: "2026-10-18T10:00:00Z", replaced: true },
{
user: "khoa",
mine: false,
at: "2026-10-18T10:00:00Z",
replaced: true,
state: "outdated",
},
],
unrecorded: 1,
});
@@ -362,6 +371,12 @@ describe("Artifacts", () => {
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
// Each install says whether its copy is the newest version.
expect(
within(box)
.getAllByTestId("install-state")
.map((b) => b.textContent)
).toEqual(["Latest", "Old version"]);
// Over the page, outside the card list that clips its overflow — and a
// click inside it keeps it open.
expect(box.parentElement).toBe(document.body);
@@ -394,6 +409,65 @@ describe("Artifacts", () => {
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
});
it("Share again takes the server's copy, else uploads the picked folder onto that skill", async () => {
vi.mocked(api.artifacts.uploadSkills).mockClear();
vi.mocked(api.artifacts.settings).mockResolvedValue({
serverMode: true,
canShare: true,
installsEnabled: true,
canManage: true,
});
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [item({ id: 2, kind: "skill", title: "sg-flow", source: "sg-flow", shared: true })],
});
vi.mocked(api.artifacts.reshare).mockResolvedValueOnce({
item: item({ id: 2 }),
changed: true,
});
const user = userEvent.setup();
open();
const card = await screen.findByTestId("artifact-2");
await menuAction(user, "Share again", card);
await waitFor(() => expect(api.artifacts.reshare).toHaveBeenCalledWith(2));
expect(api.artifacts.uploadSkills).not.toHaveBeenCalled();
// No copy on the server: the folder is asked for and lands on THIS row.
vi.mocked(api.artifacts.reshare).mockRejectedValueOnce(
Object.assign(new Error("no copy"), { code: "ENOSOURCE" })
);
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
vi.mocked(api.artifacts.uploadSkills).mockResolvedValueOnce({
items: [{ ...item({ id: 2 }), changed: true }],
});
await menuAction(user, "Share again", card);
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
const md = new File(["---\nname: sg-flow\n---\n"], "SKILL.md");
await user.upload(screen.getByTestId("artifact-reshare-folder"), md);
await waitFor(() =>
expect(api.artifacts.uploadSkills).toHaveBeenCalledWith([{ file: md, path: "SKILL.md" }], {
id: "2",
})
);
});
it("says when a skill's content last changed, not when it was renamed", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({
id: 2,
kind: "skill",
title: "deploy",
source: "deploy",
updatedAt: new Date().toISOString(),
versionAt: new Date(Date.now() - 3 * 3600_000).toISOString(),
}),
],
});
open();
const card = await screen.findByTestId("artifact-2");
expect(within(card).getByText("Updated 3h ago")).toBeInTheDocument();
});
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.44 (rebuild client to match)
UI build v2.8.49 (rebuild client to match)
</span>
</p>
</div>
+7 -5
View File
@@ -1203,7 +1203,7 @@ GET /api/me/git { account: "member"|"host", writable, ssh: {
POST /api/me/git/ssh-key Make an SSH key — Body: { replace?, type?: "ed25519" | "rsa", name? } — without `name` the DEFAULT key of that type (`id_ed25519`/`id_rsa`), with `name` (lowercase letters, digits, `-`, ≤ 32; 400 EBADKEYNAME) one more beside it, `id_<type>_<name>` — for another AWS account or a second GitHub user. 409 EEXISTS when that key exists without replace, or the name is taken by a key of the other type; 409 ETOOMANYKEYS past 10 keys → 201, same shape
DELETE /api/me/git/ssh-key Delete one key — `?id=<key id>` (400 EBADKEYNAME for a malformed id), or the default key of `?type=ed25519|rsa` — or every key without either (404 ENOTFOUND, 400 EBADKEYTYPE). A login naming a deleted key goes back to every key; our terminal `core.sshCommand` follows the keys and goes with the last one
PUT /api/me/git/ssh-terminal Use the keys for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (every AgentHub key first, then `~/.ssh/id_*`; rewritten as keys are made or deleted while on); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key? } — writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. 400 EBADHOST / EBADSSHUSER
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key? } — writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. An IAM SSH key ID logs in to every region of its AWS account, so a Clone of `ssh://git-codecommit.<region>.amazonaws.com/…` whose region has no login of its own borrows the ones saved for other CodeCommit regions (each with its own key): the first the remote accepts (`git ls-remote`), or the only one, and the clone's `origin` then names that user. 400 EBADHOST / EBADSSHUSER
DELETE /api/me/git/ssh-user/:from Drop the rule of that host or alias and its key link (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
POST /api/me/git/https Save an https login — Body: { host, username, password }
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
@@ -3147,7 +3147,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
### Artifacts hub
```http
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` and `updateAvailable` (default scope `all`)
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`)
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
@@ -3156,7 +3156,7 @@ POST /api/artifacts/page {url, title?, description?}
POST /api/artifacts/file {laneId, path, title?, sessionId?}
POST /api/artifacts/file/upload multipart: files (one), title?, description? → {item}
POST /api/artifacts/skill {name, laneId?, title?, description?}
POST /api/artifacts/skill/upload multipart: files, paths (JSON, each file's relative path), name?, title?, description? → {items}
POST /api/artifacts/skill/upload multipart: files, paths (JSON, each file's relative path), name?, title?, description?, id? → {items} — a skill named like one the caller already has becomes its new version (`replaced`, `changed`); `id` = Share again onto that row
POST /api/artifacts/skill/new {name, description, content, title?} → {item, items}
POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]}
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?}
@@ -3166,7 +3166,8 @@ DELETE /api/artifacts/:id {ok, kept} — kept: a shared row
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, state?, paths?}], unrecorded} — state (skills): latest | outdated | removed | unknown, that install's Claude skill copies against the newest version now
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
POST /api/artifacts/:id/decline → {id, declined} — the suggested user
POST /api/artifacts/:id/assign {userId} → {item} — admin
@@ -3175,8 +3176,9 @@ POST /api/artifacts/:id/assign {userId} → {item} — admin
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked and not a web app's own page: never an `index.html`, one under `www`/`public`/`dist`/`build`/`src`/`static`/`templates`/`assets`/`site`/`_site`, or one beside `.js`/`.css`-type files in a folder other than the workspace root — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`.
- **Share again** (a skill's ⋯ menu and detail sheet, its owner, server mode) — `reshare` takes the owner's skill folder on THIS server as the new snapshot: the row's source folder, else the same-named `skills/<name>` of a plugin the owner installed at user scope (`installed_plugins.json`, the most recently updated one, never outside their `~/.claude/plugins/`), and shares the row; installers are then offered **Update**. With no copy on the server (a skill uploaded from a browser) it answers `409 ENOSOURCE` and the page asks for the skill's folder, uploaded with `id` onto that row. An upload whose skill is named like one the uploader already has (not a workspace's) also replaces that row's version rather than adding a twin.
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
+5 -1
View File
@@ -555,6 +555,10 @@ agenthub accounts pin team-key mia --tool codex # pins work as for Claude
is used only for a pane that runs as its developer (one Linux user per
account): AgentHub writes the account's `auth.json` into that developer's
`~/.codex` as them (0600) just before the pane starts.
- The pane runs `codex --no-daemon` (when that Codex lists the flag in
`--help`): Codex ≥ 0.164 otherwise hands a plain session to the home's
shared app-server daemon, which outlives every pane and keeps the login
it started with — the new `auth.json` would not be used.
- A host pane never gets one: its `CODEX_HOME` holds the host's own login.
- A developer who signed Codex in themselves keeps their own login.
- While a pane of theirs runs on a pool account, their next pane uses the
@@ -882,7 +886,7 @@ What runs as the member, and how:
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — clone `ssh://<name>/v1/repos/<repo>`; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — clone `ssh://<name>/v1/repos/<repo>`; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone also lends a region with no key ID of its own the key IDs saved for other regions (an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
- the workspace prompt and its context files;
- the AI Proxy settings: no key, only that pane's own token for the gateway;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.44",
"version": "2.8.49",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.44",
"version": "2.8.49",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.44",
"version": "2.8.49",
"bin": {
"agenthub": "bin/agenthub.js"
},
+139
View File
@@ -665,6 +665,128 @@ describe("skills from files or written", () => {
assert.equal(bad.body.error.code, "EBADNAME");
});
it("updates the uploader's same-named skill instead of adding a twin", async () => {
const md = (v) => `---\nname: sg-again\ndescription: version ${v}\n---\nBody ${v}.\n`;
const first = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(1)]]);
assert.equal(first.status, 201, JSON.stringify(first.body));
const id = first.body.items[0].id;
const again = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]);
assert.equal(again.status, 201, JSON.stringify(again.body));
assert.equal(again.body.items[0].id, id);
assert.equal(again.body.items[0].replaced, true);
assert.equal(again.body.items[0].changed, true);
assert.equal(again.body.items[0].description, "version 2");
assert.match(snapText(again.body.items[0]), /Body 2\./);
const rows = db
.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill' AND source = 'sg-again'")
.get();
assert.equal(rows.n, 1);
const same = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]);
assert.equal(same.body.items[0].changed, false);
});
it("dates a skill by its content, and tells each install's copy from the newest", async () => {
const md = (v) => `---\nname: sg-state\ndescription: d\n---\nState ${v}.\n`;
const first = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(1)]]);
const id = first.body.items[0].id;
const at1 = first.body.items[0].versionAt;
assert.ok(at1);
const states = async () =>
(await request("GET", `/api/artifacts/${id}/installs`)).body.items.map((i) => i.state);
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
assert.deepEqual(await states(), ["latest"]);
// A rename is no new version; new content is.
const renamed = await request("PATCH", `/api/artifacts/${id}`, { title: "State" });
assert.equal(renamed.body.item.versionAt, at1);
await new Promise((r) => setTimeout(r, 5));
const next = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(2)]]);
assert.ok(next.body.items[0].versionAt > at1);
assert.deepEqual(await states(), ["outdated"]);
fs.rmSync(path.join(process.env.CLAUDE_HOME, "skills", "sg-state"), { recursive: true });
assert.deepEqual(await states(), ["removed"]);
});
it("Share again: an uploaded skill asks for its folder, which replaces that row", async () => {
const md = (v) => `---\nname: sg-up\ndescription: d\n---\nUp ${v}.\n`;
const first = await upload("/api/artifacts/skill/upload", [["sg-up/SKILL.md", md(1)]]);
const id = first.body.items[0].id;
const no = await request("POST", `/api/artifacts/${id}/reshare`);
assert.equal(no.status, 409);
assert.equal(no.body.error.code, "ENOSOURCE");
// The folder picked again, under any folder name: onto THIS row.
const r = await upload("/api/artifacts/skill/upload", [["renamed/SKILL.md", md(2)]], {
id: String(id),
});
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.equal(r.body.items[0].id, id);
assert.equal(r.body.items[0].source, "sg-up");
assert.match(snapText(r.body.items[0]), /^---\nname: sg-up\n[\s\S]*Up 2\./);
const two = await upload(
"/api/artifacts/skill/upload",
[
["p/a/SKILL.md", "---\nname: a\ndescription: d\n---\n"],
["p/b/SKILL.md", "---\nname: b\ndescription: d\n---\n"],
],
{ id: String(id) }
);
assert.equal(two.body.error.code, "EBADUPLOAD");
});
it("Share again takes the owner's folder, else the skill of an installed plugin", async () => {
const home = process.env.CLAUDE_HOME;
// An uploaded skill whose name a user-scope plugin also ships.
const first = await upload("/api/artifacts/skill/upload", [
["sg-plug/SKILL.md", "---\nname: sg-plug\ndescription: d\n---\nOld.\n"],
]);
const id = first.body.items[0].id;
const writePlugin = (ver, text) => {
const at = path.join(home, "plugins", "cache", "m", "p", ver);
fs.mkdirSync(path.join(at, "skills", "sg-plug"), { recursive: true });
fs.writeFileSync(path.join(at, "skills", "sg-plug", "SKILL.md"), text);
return at;
};
const v1 = writePlugin("1.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin one.\n");
const v2 = writePlugin("2.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin two.\n");
fs.writeFileSync(
path.join(home, "plugins", "installed_plugins.json"),
JSON.stringify({
version: 2,
plugins: {
"p@m": [{ scope: "user", installPath: v2, lastUpdated: "2026-10-30T00:00:00Z" }],
"old@m": [{ scope: "user", installPath: v1, lastUpdated: "2026-01-01T00:00:00Z" }],
"proj@m": [
{ scope: "project", installPath: "/etc", lastUpdated: "2027-01-01T00:00:00Z" },
],
},
})
);
const r = await request("POST", `/api/artifacts/${id}/reshare`);
assert.equal(r.status, 200, r.text);
assert.equal(r.body.changed, true);
assert.match(snapText(r.body.item), /Plugin two\./);
assert.equal((await request("POST", `/api/artifacts/${id}/reshare`)).body.changed, false);
// The owner's own ~/.claude/skills folder outranks a plugin's copy.
const own = path.join(home, "skills", "sg-plug");
fs.mkdirSync(own, { recursive: true });
fs.writeFileSync(
path.join(own, "SKILL.md"),
"---\nname: sg-plug\ndescription: d\n---\nMine.\n"
);
const mine = await request("POST", `/api/artifacts/${id}/reshare`);
assert.equal(mine.body.changed, true);
assert.match(snapText(mine.body.item), /Mine\./);
// Recorded as the folder's state: the 15 s sync does not take it twice.
assert.deepEqual(
artifacts.syncSkillSources({ force: true }).filter((c) => c.id === id),
[]
);
fs.rmSync(path.join(home, "plugins"), { recursive: true, force: true });
fs.rmSync(own, { recursive: true, force: true });
});
it("keeps an uploaded file as a snapshot, by its own base name", async () => {
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], {
title: "Report",
@@ -859,6 +981,23 @@ describe("outputs captured at a session's stop", () => {
);
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
});
it("leaves a web app's own pages alone and still takes a standalone HTML report", async () => {
const put = (rel, text = "<p>x</p>") => {
fs.mkdirSync(path.dirname(path.join(ws, rel)), { recursive: true });
fs.writeFileSync(path.join(ws, rel), text);
const soon = new Date(Date.now() + 5 * 60 * 1000);
fs.utimesSync(path.join(ws, rel), soon, soon);
};
put("app/index.html");
put("app/www/page.html");
put("widget/view.html");
put("widget/view.js", "x()");
put("summary-report.html");
put("out/q3.html");
const got = (await artifacts.captureOutputs("s1")).map((r) => r.source).sort();
assert.deepEqual(got, ["out/q3.html", "summary-report.html"]);
});
});
describe("unowned rows", () => {
+27 -1
View File
@@ -243,6 +243,29 @@ describe("codex pool: spawn", () => {
assert.equal(row.account, "c");
});
it("runs a ChatGPT pane without the shared daemon, only when its Codex has the flag", () => {
// The home's app-server daemon keeps the login it started with: a placed
// login reaches only a pane that runs its own app server.
const fakeCodex = (name, help) => {
const file = path.join(TMP, name);
fs.writeFileSync(file, `#!/bin/sh\necho '${help}'\n`, { mode: 0o755 });
return file;
};
const prev = process.env.AGENTHUB_CODEX_BIN;
try {
pool.addAccount("c", { auth: AUTH("acct-c") });
process.env.AGENTHUB_CODEX_BIN = fakeCodex("codex-new", " --no-daemon Run without");
assert.deepEqual(pool.forSpawn(MIA).argv, ["--no-daemon"]);
process.env.AGENTHUB_CODEX_BIN = fakeCodex("codex-old", " --oss Use a local model");
assert.deepEqual(pool.forSpawn(MIA).argv, [], "an older Codex would refuse the flag");
process.env.AGENTHUB_CODEX_BIN = path.join(TMP, "no-such-codex");
assert.deepEqual(pool.forSpawn(MIA).argv, []);
} finally {
if (prev === undefined) delete process.env.AGENTHUB_CODEX_BIN;
else process.env.AGENTHUB_CODEX_BIN = prev;
}
});
it("never replaces a member's own login, and stays on the account their live pane uses", () => {
pool.addAccount("c", { auth: AUTH("acct-c") });
pool.addAccount("d", { auth: AUTH("acct-d") });
@@ -272,7 +295,10 @@ describe("codex pool: spawn", () => {
fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
assert.equal(pool.pickAccount(MIA), null);
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
assert.equal(JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token, "rt-mias-seat");
assert.equal(
JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token,
"rt-mias-seat"
);
});
it("copies the newest auth.json back into the pool", () => {
+39 -1
View File
@@ -5,7 +5,7 @@
* ECREDENTIALS for a remote wanting a login), `/api/me/git` (key made once,
* only the public half returned, 0600 on disk; named keys beside the default
* ones, SSH logins per alias tied to one key, which a clone then offers
* alone), and the member (S3) path of
* alone; a CodeCommit region with no login borrowing another region's), and the member (S3) path of
* `server/lib/git-identity.js` with run-as and git stubbed — what reaches
* git's argv, and that a login typed without "remember" leaves the member's
* store as it was.
@@ -650,6 +650,44 @@ describe("several accounts: named keys and SSH logins per alias", () => {
});
});
describe("CodeCommit: a region with no login borrows another region's", () => {
it("clones as the first saved CodeCommit user the remote accepts, and keeps that user in origin", async () => {
const bare = path.join(ROOT, "bare.git");
const R = "git-codecommit.ap-southeast-2.amazonaws.com";
const saved = [
["git-codecommit.ap-southeast-1.amazonaws.com", "APKAONE"],
["git-codecommit.us-east-1.amazonaws.com", "APKATWO"],
];
for (const [host, user] of saved) {
assert.equal((await request("PUT", "/api/me/git/ssh-user", { host, user })).status, 200);
}
// The test forge stands in for the region: APKAONE is refused (no such
// repo behind it), APKATWO is the account that owns the repo.
const rules = [
[`url.file://${ROOT}/nowhere.git.insteadOf`, `ssh://APKAONE@${R}/v1/repos/r`],
[`url.file://${bare}.insteadOf`, `ssh://APKATWO@${R}/v1/repos/r`],
];
for (const [k, v] of rules) execFileSync("git", ["config", "--global", "--add", k, v]);
try {
const r = await request("POST", "/api/lanes/clone", {
url: `ssh://${R}/v1/repos/r`,
parent: WORK,
name: "borrowed-login",
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const origin = execFileSync(
"git",
["-C", path.join(WORK, "borrowed-login"), "config", "remote.origin.url"],
{ encoding: "utf8" }
).trim();
assert.equal(origin, `ssh://APKATWO@${R}/v1/repos/r`);
} finally {
for (const [k] of rules) execFileSync("git", ["config", "--global", "--unset-all", k]);
for (const [host] of saved) await request("DELETE", `/api/me/git/ssh-user/${host}`);
}
});
});
describe("git-identity, member account (S3, stubbed)", () => {
const runAs = require("../lib/run-as");
const worktree = require("../lib/worktree");
+5
View File
@@ -973,11 +973,16 @@ for (const [col, ddl] of [
["suggested_owner_id", "suggested_owner_id INTEGER"],
["version", "version TEXT"],
["source_hash", "source_hash TEXT"],
["version_at", "version_at TEXT"],
]) {
try {
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
} catch {
db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run();
// `version_at`: when a row's CONTENT last changed (a new snapshot) — a
// rename or a share toggle moves `updated_at` but not this. An existing
// row starts from its `updated_at`, the best record there is.
if (col === "version_at") db.prepare("UPDATE artifacts SET version_at = updated_at").run();
}
}
+222 -16
View File
@@ -173,6 +173,8 @@ function view(row, req, owners = null) {
suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id,
createdAt: row.created_at,
updatedAt: row.updated_at,
// When its content last changed (a new snapshot): a page or plugin never does.
versionAt: row.version_at || row.created_at,
};
}
@@ -549,13 +551,38 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
const OUTPUT_MAX = 20;
/** Folders that hold a site's own pages, never a report someone reads. */
const SITE_DIR_RE = /^(www|public|dist|build|src|static|templates|assets|site|_site)$/i;
/** What sits next to a site's page: its scripts and styles. */
const SITE_ASSET_RE = /\.(m?js|jsx|tsx?|vue|svelte|css|s[ac]ss|less)$/i;
/**
* An HTML file that is a web app's own page, not a report (`app/www/index.html`
* of a project not yet committed): an `index.html`, one under a site folder
* (`SITE_DIR_RE`), or one beside scripts or stylesheets in its folder. The
* workspace root is exempt from that last test — a repo keeps its config
* `.js` there next to the reports people write.
*/
async function isSitePage(cwd, rel) {
const parts = rel.split("/");
const name = parts.pop();
if (/^index\.html?$/i.test(name)) return true;
if (parts.some((d) => SITE_DIR_RE.test(d))) return true;
if (!parts.length) return false;
try {
const names = await fs.promises.readdir(path.join(cwd, ...parts));
return names.some((n) => SITE_ASSET_RE.test(n));
} catch {
return false;
}
}
const capturing = new Map();
/**
* The workspace's documents a session created or changed since it started:
* untracked or modified files in a repo (an HTML file only when untracked, so a
* site's own pages are not taken), every file of a plain folder. Paths are
* workspace-relative.
* untracked or modified files in a repo (an HTML file only when untracked and
* not a site's own page — `isSitePage`), every file of a plain folder. Paths
* are workspace-relative.
*/
async function outputCandidates(lane, since) {
const gd = require("./git-diff");
@@ -572,7 +599,7 @@ async function outputCandidates(lane, since) {
const out = [];
for (const rel of new Set(paths)) {
if (!OUTPUT_EXT.test(rel)) continue;
if (/\.html?$/i.test(rel) && tracked.has(rel)) continue;
if (/\.html?$/i.test(rel) && (tracked.has(rel) || (await isSitePage(lane.cwd, rel)))) continue;
let st;
try {
st = await fs.promises.lstat(path.join(lane.cwd, rel));
@@ -627,7 +654,7 @@ async function captureOutputsNow(laneId, sessionId) {
const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]);
if (row) {
sql(
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(shot.snap.id, files, shot.size, String(sessionId), row.id);
dropSnapshot(row.snapshot);
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
@@ -1125,8 +1152,20 @@ function saveSkillFiles(req, { name, files, title, description }) {
* `paths`). Answers every skill it made. Nothing is stored unless every
* skill in the upload passes.
*/
function uploadSkills(req, { uploads, name, title, description }) {
function uploadSkills(req, { uploads, name, title, description, id }) {
// `id`: Share again for a skill the server has no copy of — the upload
// replaces THAT row (one skill, named as the row is).
let onto = null;
if (id != null && id !== "") {
onto = load(req, id);
if (!canChange(req, onto)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
if (onto.kind !== "skill") throw fail("EBADKIND", "only a skill is shared again");
name = onto.source;
}
const found = skillsFromUpload(uploads || [], { name, description });
if (onto && found.length !== 1) {
throw fail("EBADUPLOAD", "choose the folder or .zip of this one skill");
}
const typed = typeof name === "string" && name.trim();
const prepared = found.map((sk) => ({
name: validSkillName(found.length === 1 && typed ? typed : sk.fallbackName || ""),
@@ -1140,16 +1179,46 @@ function uploadSkills(req, { uploads, name, title, description }) {
withFrontmatter(md.data.toString("utf8"), p.name, found.length === 1 ? description : "");
checkSkillFiles(p.files);
}
return prepared.map((p) =>
saveSkillFiles(req, {
return prepared.map((p) => {
// A skill its uploader already has (same name, not a workspace's) gets
// the new version on that row — installers see Update — never a twin.
const own = onto || ownSkillRow(req, p.name);
if (own) return replaceUploaded(req, own, p, found.length === 1 ? description : "");
return saveSkillFiles(req, {
name: p.name,
files: p.files,
title: found.length === 1 ? title : "",
description: found.length === 1 ? description : "",
})
});
});
}
/** The caller's newest skill row of that name taken from no workspace, or null. */
function ownSkillRow(req, name) {
const owner = req?.user ? req.user.id : null;
return (
sql(
`SELECT * FROM artifacts WHERE kind = 'skill' AND source = ? AND lane_id IS NULL
AND snapshot IS NOT NULL AND owner_id IS ? ORDER BY updated_at DESC, id DESC LIMIT 1`
).get(name, owner) || null
);
}
/** An uploaded skill onto an existing row: same frontmatter rules as a new one. */
function replaceUploaded(req, row, { name, files }, description) {
const md = files.find((f) => f.path === "SKILL.md");
const text = md.data.toString("utf8");
if (text.includes("\uFFFD")) throw fail("EBADSKILL", "SKILL.md is not a UTF-8 text file");
const fixed = Buffer.from(withFrontmatter(text, name, description), "utf8");
const all = [{ path: "SKILL.md", data: fixed }, ...files.filter((f) => f !== md)];
checkSkillFiles(all);
const changed = contentHash(all) !== snapshotVersion(row);
// The owner's folder as it is now (like `storeSkill`): only a LATER change
// to it replaces this upload.
if (changed) replaceSnapshot(row, all, sourceHash(row) ?? row.source_hash);
return { ...shareNow(req, row.id, changed), replaced: true, changed };
}
/** A skill written in the dialog: a name, a description and its instructions. */
function writeSkill(req, { name, description, content, title }) {
const n = validSkillName(name);
@@ -1599,7 +1668,8 @@ function editSkill(req, row, { name, description, content }) {
sql(
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
version = ?, source_hash = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(
n,
snap.id,
@@ -1936,6 +2006,23 @@ function syncOne(row) {
} catch {
return false;
}
try {
replaceSnapshot(row, files, st.hash);
} catch {
return false;
}
return true;
}
/**
* Point a skill row at a NEW snapshot holding `files` (`[{path, data}]`, already
* checked), then drop the old one — never a write into the snapshot being
* read. Installers then see Update (`updateCheck`). `sourceHash` is the
* source folder's state to record, so `syncOne` takes only a LATER change.
* The description follows SKILL.md's only while it still says what the old
* one did.
*/
function replaceSnapshot(row, files, sourceHash) {
const snap = newSnapshotDir();
let total = 0;
const next = [];
@@ -1947,11 +2034,10 @@ function syncOne(row) {
total += f.data.length;
next.push({ path: f.path, size: f.data.length });
}
} catch {
} catch (err) {
dropSnapshot(snap.id);
return false;
throw err;
}
// The description follows SKILL.md's only while it still says what the old one did.
const md = (list) => list.find((f) => f.path === "SKILL.md");
let oldSummary = "";
try {
@@ -1969,10 +2055,98 @@ function syncOne(row) {
: row.description;
sql(
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?,
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(snap.id, JSON.stringify(next), total, st.hash, st.hash, description, row.id);
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(
snap.id,
JSON.stringify(next),
total,
contentHash(files),
sourceHash ?? null,
description,
row.id
);
if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot);
return true;
}
/**
* The owner's skill folder on THIS server as it is now, for Share again:
* the row's own source folder (`sourceDirOf`), else — a skill not taken from
* a workspace — the same-named skill of a plugin the owner installed at user
* scope (`installed_plugins.json`; the most recently updated when several
* have it), never outside the owner's `~/.claude/plugins/`. Answers
* `{st, fromSource}` (`folderState`) or null when the server has no copy (an
* uploaded skill: its owner picks the folder again).
*/
function machineSkill(row) {
const src = sourceDirOf(row);
const st = src && folderState(src.dir, src.top);
if (st) return { st, fromSource: true };
if (!src || row.lane_id != null) return null;
const top = path.join(src.top, "plugins");
let installed = {};
try {
const user = sql("SELECT id, username, role FROM users WHERE id = ?").get(row.owner_id);
const file = path.join(top, "installed_plugins.json");
const text =
user && ownAccount({ user }).acct
? require("./run-as").readFileSafe(file, "utf8", { max: 4 << 20 })
: fs.readFileSync(file, "utf8");
installed = JSON.parse(text)?.plugins || {};
} catch {
return null;
}
let best = null;
for (const entries of Object.values(installed)) {
for (const e of Array.isArray(entries) ? entries : []) {
if (!e || e.scope !== "user" || typeof e.installPath !== "string") continue;
const st2 = folderState(path.join(e.installPath, "skills", row.source), top);
const at = Date.parse(e.lastUpdated || e.installedAt || "") || 0;
if (st2 && (!best || at > best.at)) best = { st: st2, at };
}
}
return best ? { st: best.st, fromSource: false } : null;
}
/**
* Share again: take the owner's skill folder on this server as the row's new
* snapshot (`machineSkill`) and share it (server mode), so installers are
* offered Update. ENOSOURCE when the server has no copy — the client then
* asks for the folder or .zip and uploads it onto this row (`uploadSkills`
* with `id`). Answers `{item, changed}`.
*/
function reshareSkill(req, id) {
const row = load(req, id);
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill is shared again");
const found = machineSkill(row);
if (!found) {
throw fail("ENOSOURCE", "this server has no copy of the skill: choose its folder or .zip");
}
const files = found.st.read();
checkSkillFiles(files);
const changed = found.st.hash !== snapshotVersion(row);
if (changed) {
replaceSnapshot(row, files, found.fromSource ? found.st.hash : row.source_hash);
} else if (found.fromSource) {
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(found.st.hash, row.id);
}
return { item: shareNow(req, row.id, changed), changed };
}
/** Share a row (server mode, when it is not yet) and answer its view; `audit` names a new version. */
function shareNow(req, id, changed) {
const row = sql("SELECT * FROM artifacts WHERE id = ?").get(id);
if (isServer() && row.shared !== 1) {
sql(
`UPDATE artifacts SET shared = 1, shared_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(id);
audit(req, row, "shared");
} else if (changed && row.shared === 1) {
audit(req, row, "updated");
}
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(id), req);
}
const SYNC_EVERY_MS = 15 * 1000;
@@ -2163,6 +2337,36 @@ async function uninstall(req, id) {
* the installer and an admin. `unrecorded` = installs counted before this
* list was kept.
*/
/**
* Where one recorded install of a skill stands now, from its Claude
* `skills/<name>` copies (one per account it went into; the cross-tool
* `~/.agents/skills` twins are the same files) —
* `outdated` when any differs from the row's newest snapshot (its user is
* offered Update), `removed` when none is there any more, `unknown` when one
* is there but unreadable as a skill, else `latest`. Only hashes are
* compared; no content leaves the server. The paths are the ones the server
* itself recorded at install, read like `updateCheck` reads the viewer's own.
*/
function installState(row, paths) {
const version = snapshotVersion(row);
const dirs = (Array.isArray(paths) ? paths : []).filter(
(p) =>
typeof p === "string" &&
path.basename(p) === row.source &&
path.basename(path.dirname(p)) === "skills" &&
path.basename(path.dirname(path.dirname(p))) !== ".agents"
);
if (!version || !dirs.length) return "unknown";
const states = dirs.map((dir) => {
const st = folderState(dir, path.dirname(path.dirname(dir)));
if (st) return st.hash === version ? "latest" : "outdated";
return exists(dir) ? "unknown" : "removed";
});
if (states.includes("outdated")) return "outdated";
if (states.every((x) => x === "removed")) return "removed";
return states.includes("unknown") ? "unknown" : "latest";
}
function installsOf(req, id) {
const row = load(req, id);
const u = req?.user;
@@ -2186,6 +2390,7 @@ function installsOf(req, id) {
mine,
at: r.installed_at,
replaced: r.replaced === 1,
...(row.kind === "skill" ? { state: installState(row, paths) } : {}),
...(mine || u?.role === "admin" ? { paths } : {}),
};
});
@@ -2215,6 +2420,7 @@ module.exports = {
availableSkills,
addSkill,
uploadSkills,
reshareSkill,
writeSkill,
uploadFile,
skillSlug,
+44 -2
View File
@@ -19,7 +19,9 @@
* home AgentHub placed that account in, never dated in the future, and
* only when its id_token names the same account (`mayCopyBack`); the 2026-10-08
* spike showed an old refresh token still refreshes, so panes sharing an
* account do not log each other out.
* account do not log each other out. Such a pane runs `--no-daemon`
* (when its Codex has the flag): the home's shared app-server daemon
* would keep serving the login it started with.
*
* Records live in `<CLAUDE_HOME>/agenthub-codex-accounts/<name>.json` (dir
* 0700, file 0600); no route returns a key or a token.
@@ -424,6 +426,46 @@ function liveInHome(name, member) {
});
}
// Codex ≥ 0.164 hands a plain TUI (no `-c` override) to the CODEX_HOME's shared
// app-server daemon, which outlives every TUI and keeps the login it started
// with — so a ChatGPT login placed in the home reached no pane while the
// member's daemon ran on the previous one (reproduced on 0.164.0). A pool pane
// runs its own app server instead. An older Codex has neither the daemon nor
// the flag (it would refuse to start), so the flag goes only to a binary whose
// --help lists it: probed once per binary (path, size, mtime).
const noDaemonCache = new Map();
function noDaemonArgv(member) {
const bin =
process.env.AGENTHUB_CODEX_BIN ||
(member
? require("./shared-tools").sharedPath("codex")
: require("./ai-tools").detect("codex").path);
if (!bin) return [];
let key;
try {
const st = fs.statSync(bin);
key = `${fs.realpathSync(bin)}:${st.size}:${st.mtimeMs}`;
} catch {
return [];
}
if (!noDaemonCache.has(key)) {
let help = "";
try {
help = require("node:child_process").execFileSync(bin, ["--help"], {
encoding: "utf8",
timeout: 5000,
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
});
} catch {
/* no answer: leave the flag out, as before */
}
noDaemonCache.set(key, /--no-daemon\b/.test(help));
}
return noDaemonCache.get(key) ? ["--no-daemon"] : [];
}
/** `{name, kind}` of the account a new Codex pane would get, or null. */
function pickAccount(member = null, ownerId = null) {
const a = pick(member, ownerId);
@@ -449,7 +491,7 @@ function forSpawn(member = null, ownerId = null) {
}
return {
name: a.name,
argv: [],
argv: noDaemonArgv(member),
env: {},
place: () => {
syncHome(member.home); // a live pane may hold a newer login than ours
+64 -5
View File
@@ -715,6 +715,42 @@ async function linkedKeyFile(account, url) {
return row ? (currentKeys(account).find((k) => k.id === row.key)?.file ?? null) : null;
}
// AWS CodeCommit's SSH key ID belongs to an IAM user, so it logs in to EVERY
// region of that AWS account — but our login rules are per host, and a repo
// in a region the user never saved stayed refused. Such a clone borrows the
// logins saved for the other CodeCommit regions (not aliases' hosts only: an
// alias row's user is as valid), each with its own key when it names one.
const CODECOMMIT_RE = /^git-codecommit\.[a-z0-9-]+\.amazonaws\.com$/;
/** `[{url, keyFile}]`: `url` with each borrowable CodeCommit user in it. */
async function borrowedLogins(account, url) {
if (!url.startsWith("ssh://")) return [];
let u;
try {
u = new URL(url);
} catch {
return [];
}
const host = u.host.toLowerCase();
if (u.username || !CODECOMMIT_RE.test(host)) return [];
const rows = await listSshUsers(account);
// The region has a login of its own: git's insteadOf already applies it.
if (rows.some((r) => (r.alias ?? r.host) === host)) return [];
const keys = currentKeys(account);
const seen = new Set();
const out = [];
for (const r of rows) {
if (!CODECOMMIT_RE.test(r.host)) continue;
const keyFile = r.key ? (keys.find((k) => k.id === r.key)?.file ?? null) : null;
if (r.key && !keyFile) continue;
const id = `${r.user}\0${keyFile ?? ""}`;
if (seen.has(id)) continue;
seen.add(id);
out.push({ url: `ssh://${r.user}@${host}${u.pathname}`, keyFile });
}
return out;
}
// ── Network commands in an existing repo (git bar fetch/pull/push) ─────────
/**
@@ -878,12 +914,35 @@ async function cloneNow({ url, parent, name, once = null, partial = false }, thr
if (once) throw err("EBADCRED", "a typed login is for https only — SSH uses your key");
// A login naming its key (another AWS account, a second GitHub user)
// offers that key ONLY — another key could log in as the wrong account.
const linked = await linkedKeyFile(account, url);
let linked = await linkedKeyFile(account, url);
const sshFor = (file) => {
const k = file ? [file] : keyFiles(account);
const b = k.length ? `ssh${identityArgs(k)} -o IdentitiesOnly=yes` : "ssh";
// BatchMode: no terminal to answer a prompt; accept-new: first contact
// records the host key, a CHANGED one still fails.
return `core.sshCommand=${b} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
};
// A CodeCommit region with no login of its own: the first borrowed login
// the remote accepts (one is taken as is). The clone's URL then names
// that user, so the repo's `origin` keeps logging in the same way.
const borrowed = await borrowedLogins(account, url);
for (const b of borrowed) {
const ok =
borrowed.length === 1 ||
(await git(parent, ["-c", sshFor(b.keyFile), "ls-remote", "--", b.url, "HEAD"], {
timeout: 60_000,
}).then(
() => true,
() => false
));
if (ok) {
url = b.url;
linked = b.keyFile;
break;
}
}
const keys = linked ? [linked] : keyFiles(account);
const base = keys.length ? `ssh${identityArgs(keys)} -o IdentitiesOnly=yes` : "ssh";
// BatchMode: no terminal to answer a prompt; accept-new: first contact
// records the host key, a CHANGED one still fails.
pre.push("-c", `core.sshCommand=${base} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`);
pre.push("-c", sshFor(linked));
if (keys.length) {
// Persisted by FILE name — both default types plus every named key:
// a default key made later is offered too (ssh skips a missing
+2 -1
View File
@@ -238,7 +238,7 @@ const paths = {
summary: "Skills from files: a file, a folder, a .zip/.skill or several skills",
operationId: "uploadArtifactSkills",
description:
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.",
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails. A skill whose name matches one the caller already has (not a workspace's) becomes that row's new version (`replaced: true`, `changed`) instead of a second row; `id` (Share again) puts the ONE uploaded skill onto that row, under the row's name.",
requestBody: multipart(
{
files: { type: "array", items: { type: "string", format: "binary" } },
@@ -246,6 +246,7 @@ const paths = {
name: { type: "string" },
title: { type: "string" },
description: { type: "string" },
id: { type: "string", description: "Share again: the skill row the upload replaces." },
},
["files"]
),
+20
View File
@@ -1113,6 +1113,26 @@ const paths = {
},
},
},
"/api/artifacts/{id}/reshare": {
post: {
tags: ["Artifacts"],
summary: "Share again: a skill's newest version from its owner's folder on this server",
operationId: "reshareArtifact",
description:
"The owner's skill folder (the row's source folder, else the same-named skill of a plugin the owner installed at user scope) becomes the new snapshot, and the row is shared (server mode); installers are then offered Update. 409 ENOSOURCE when the server has no copy: upload the folder with `id` on /api/artifacts/skill/upload. Same-origin only.",
parameters: [pathParam("id", "Artifact id.")],
responses: {
200: ok("Taken.", {
item: { type: "object" },
changed: { type: "boolean" },
}),
400: errs("EBADKIND."),
403: errs("EFORBIDDEN."),
404: errs("ENOTFOUND."),
409: errs("ENOSOURCE."),
},
},
},
"/api/artifacts/{id}/claim": {
post: {
tags: ["Artifacts"],
+15
View File
@@ -39,6 +39,7 @@ const STATUS = {
ENEEDLOGIN: 502,
ETOOBIG: 413,
EOWNED: 409,
ENOSOURCE: 409,
};
function sendError(res, err) {
@@ -229,6 +230,7 @@ router.post(
name: b.name,
title: b.title,
description: b.description,
id: b.id,
});
for (const item of items) changed(item);
res.status(201).json({ items });
@@ -284,6 +286,19 @@ router.patch(
})
);
// Share again: the owner's skill folder on this server becomes the new
// version (installers see Update); 409 ENOSOURCE when the server has none —
// the client then uploads the folder onto this row (`/skill/upload` + `id`).
router.post(
"/:id/reshare",
sameOriginGuard,
wrap((req, res) => {
const out = artifacts.reshareSkill(req, req.params.id);
changed(out.item);
res.json(out);
})
);
router.delete(
"/:id",
sameOriginGuard,