@edspencer/paddock 0.66.1 → 0.66.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/herdctl-jobs.d.ts +20 -0
  3. package/packages/server/dist/herdctl-jobs.js +94 -0
  4. package/packages/server/dist/herdctl.d.ts +46 -0
  5. package/packages/server/dist/herdctl.js +58 -0
  6. package/packages/server/dist/instance-config.d.ts +56 -5
  7. package/packages/server/dist/instance-config.js +206 -24
  8. package/packages/server/dist/project-paths.d.ts +46 -1
  9. package/packages/server/dist/project-paths.js +66 -2
  10. package/packages/server/dist/project-types.d.ts +23 -0
  11. package/packages/server/dist/project-types.js +35 -0
  12. package/packages/server/dist/projects.d.ts +5 -2
  13. package/packages/server/dist/projects.js +71 -17
  14. package/packages/server/dist/queued-message.d.ts +32 -2
  15. package/packages/server/dist/queued-message.js +87 -8
  16. package/packages/server/dist/routes/chats.js +27 -8
  17. package/packages/server/dist/routes/meta.js +32 -5
  18. package/packages/server/dist/routes/projects.js +71 -7
  19. package/packages/server/dist/ws-protocol.d.ts +52 -0
  20. package/packages/server/dist/ws-protocol.js +28 -13
  21. package/packages/server/dist/ws.js +89 -24
  22. package/packages/server/package.json +1 -1
  23. package/packages/web/dist/assets/InstanceConfigPage-DKzAHOhx.js +2 -0
  24. package/packages/web/dist/assets/{MarkdownRenderer-CRQP3Cfe.js → MarkdownRenderer-DDVO0zhl.js} +1 -1
  25. package/packages/web/dist/assets/{ProjectRedirect-Dd3h5V8h.js → ProjectRedirect-WczXTV0_.js} +1 -1
  26. package/packages/web/dist/assets/ProjectView-Dd_myWpV.js +13 -0
  27. package/packages/web/dist/assets/{ProjectsGrid-BaUE8zaF.js → ProjectsGrid-BeJj0q6Y.js} +1 -1
  28. package/packages/web/dist/assets/{arc-DqMSW0As.js → arc-DXqVnx_r.js} +1 -1
  29. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CNkQD1jU.js → architectureDiagram-3BPJPVTR-FgkfD1Bk.js} +1 -1
  30. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-BCMiOmt6.js → blockDiagram-GPEHLZMM-WwkIIJsI.js} +1 -1
  31. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CU2soCji.js → c4Diagram-AAUBKEIU-BIzkTibx.js} +1 -1
  32. package/packages/web/dist/assets/channel-DUsOl5_p.js +2 -0
  33. package/packages/web/dist/assets/{chunk-2J33WTMH-nKhlJNTt.js → chunk-2J33WTMH-C4TBH3R6.js} +1 -1
  34. package/packages/web/dist/assets/{chunk-4BX2VUAB-YRTZj9zI.js → chunk-4BX2VUAB-DTKJZlRM.js} +1 -1
  35. package/packages/web/dist/assets/{chunk-55IACEB6-HO60VE45.js → chunk-55IACEB6-BC9_Qc8h.js} +1 -1
  36. package/packages/web/dist/assets/{chunk-727SXJPM-WZJH-boe.js → chunk-727SXJPM-D3gQ4rP3.js} +1 -1
  37. package/packages/web/dist/assets/{chunk-AQP2D5EJ-PvDC7gYB.js → chunk-AQP2D5EJ-My2AXBvz.js} +1 -1
  38. package/packages/web/dist/assets/{chunk-FMBD7UC4-BNRMTplH.js → chunk-FMBD7UC4-D09zE6Ow.js} +1 -1
  39. package/packages/web/dist/assets/{chunk-ND2GUHAM-2Q2ei8TR.js → chunk-ND2GUHAM-CUSgeCLl.js} +1 -1
  40. package/packages/web/dist/assets/{chunk-QZHKN3VN-CG9VV7M6.js → chunk-QZHKN3VN-B6jVPYgy.js} +1 -1
  41. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CIdDKEqH.js +2 -0
  42. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CIdDKEqH.js +2 -0
  43. package/packages/web/dist/assets/{core-DwSEw_oE.js → core-DPY0seI-.js} +1 -1
  44. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BlMrBI7K.js → cose-bilkent-S5V4N54A-BasCiARv.js} +1 -1
  45. package/packages/web/dist/assets/{dagre-BM42HDAG-tNoaleNU.js → dagre-BM42HDAG-Cimn11Qe.js} +1 -1
  46. package/packages/web/dist/assets/{diagram-2AECGRRQ-DRVCTpHL.js → diagram-2AECGRRQ-fe9cDHyn.js} +1 -1
  47. package/packages/web/dist/assets/{diagram-5GNKFQAL-BdS-96WV.js → diagram-5GNKFQAL-T0tQlfbl.js} +1 -1
  48. package/packages/web/dist/assets/{diagram-KO2AKTUF-Dv1yxz3s.js → diagram-KO2AKTUF-dLORrSiu.js} +1 -1
  49. package/packages/web/dist/assets/{diagram-LMA3HP47-BNXgifuS.js → diagram-LMA3HP47-iUMeaR-J.js} +1 -1
  50. package/packages/web/dist/assets/{diagram-OG6HWLK6-wlO0bxwP.js → diagram-OG6HWLK6-oEH6olHK.js} +1 -1
  51. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-ChoppAEq.js → erDiagram-TEJ5UH35-VtQqF9SY.js} +1 -1
  52. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-C__aYyei.js → flowDiagram-I6XJVG4X-wvF2EkJa.js} +1 -1
  53. package/packages/web/dist/assets/{format-u-YtEZFd.js → format-CvRqOKZU.js} +1 -1
  54. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CjRe8foW.js → ganttDiagram-6RSMTGT7-ng4qJ0E1.js} +1 -1
  55. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-BGDQjiWq.js → gitGraphDiagram-PVQCEYII-BV4aWy8k.js} +1 -1
  56. package/packages/web/dist/assets/index-DGNSeqL-.js +71 -0
  57. package/packages/web/dist/assets/index-DMRbU1Jl.css +1 -0
  58. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BP-JaX-8.js → infoDiagram-5YYISTIA-BpGTxAjU.js} +1 -1
  59. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-YnsAJBvC.js → ishikawaDiagram-YF4QCWOH-Cj36LyoS.js} +1 -1
  60. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BM6JWTV9.js → journeyDiagram-JHISSGLW-0vNlg3um.js} +1 -1
  61. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-gR2O0IaL.js → kanban-definition-UN3LZRKU-wijkEDaR.js} +1 -1
  62. package/packages/web/dist/assets/{linear-Dolq_eLR.js → linear-LqRymMD_.js} +1 -1
  63. package/packages/web/dist/assets/{mermaid.core-D1T_GQW7.js → mermaid.core-DPrhSs95.js} +4 -4
  64. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-C3bzIyLZ.js → mindmap-definition-RKZ34NQL-en-uGqgC.js} +1 -1
  65. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BrZ3ANGf.js → pieDiagram-4H26LBE5-DKBZ2Vnx.js} +1 -1
  66. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-BvLCHAEi.js → quadrantDiagram-W4KKPZXB--g-WtxyL.js} +1 -1
  67. package/packages/web/dist/assets/queued-CagZVfWU.js +2 -0
  68. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CCzXAeaZ.js → requirementDiagram-4Y6WPE33-Dl4eYxDI.js} +1 -1
  69. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-2Gf-ERTg.js → sankeyDiagram-5OEKKPKP-7lt4gqSw.js} +1 -1
  70. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DMjDe28d.js → sequenceDiagram-3UESZ5HK-COgq-dXg.js} +1 -1
  71. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CENtj5dw.js → stateDiagram-AJRCARHV-CL6XC79S.js} +1 -1
  72. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BDlRyphY.js +2 -0
  73. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BW4gCpBB.js → timeline-definition-PNZ67QCA-C5egKeGT.js} +1 -1
  74. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CiIuqcT9.js → vennDiagram-CIIHVFJN-C_UhtC8u.js} +1 -1
  75. package/packages/web/dist/assets/{wardley-L42UT6IY-BQ1BPr5R.js → wardley-L42UT6IY-Bj9chUqz.js} +1 -1
  76. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DBq9Ailv.js → wardleyDiagram-YWT4CUSO-D7VnZoRp.js} +1 -1
  77. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D24VDECy.js → xychartDiagram-2RQKCTM6-VrWLDoi4.js} +1 -1
  78. package/packages/web/dist/index.html +2 -2
  79. package/packages/web/dist/sw.js +1 -1
  80. package/packages/web/dist/assets/InstanceConfigPage-1BzMnJPo.js +0 -2
  81. package/packages/web/dist/assets/ProjectView-BRk6l152.js +0 -13
  82. package/packages/web/dist/assets/channel-Ynyq4GPh.js +0 -2
  83. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-1Pp25wwy.js +0 -2
  84. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-1Pp25wwy.js +0 -2
  85. package/packages/web/dist/assets/index-B3K5nsLe.css +0 -1
  86. package/packages/web/dist/assets/index-Cc5bQxZk.js +0 -71
  87. package/packages/web/dist/assets/queued-DGmoZW59.js +0 -2
  88. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-F7o6JfFe.js +0 -2
@@ -71,9 +71,54 @@ export declare function repoCheckoutName(repo: string): string;
71
71
  * (it does NOT follow symlinks — callers that care resolve them beforehand).
72
72
  *
73
73
  * The `+ path.sep` is load-bearing: a plain `startsWith(parent)` would call
74
- * `/data/projects-old` a child of `/data/projects`.
74
+ * `/data/projects-old` a child of `/data/projects`. It does NOT hold for the one
75
+ * parent whose string already ends in a separator — the filesystem root — where
76
+ * appending would ask for `"//"` and report every child as outside (issue #719).
77
+ * That made the bidirectional overlap guard in `validatePath` fail open in both
78
+ * directions once any project's working dir was `/`, so two keepers could be
79
+ * created with overlapping working trees. Append the separator only when the
80
+ * parent doesn't already end in one.
75
81
  */
76
82
  export declare function isPathInside(child: string, parent: string): boolean;
83
+ /**
84
+ * Directories beneath which a project may never be linked (issue #720) — the
85
+ * operating system rather than anybody's work.
86
+ *
87
+ * Each entry denies itself and everything under it. The filesystem root is
88
+ * handled separately in {@link isSystemPath}: it is refused by exact match, not
89
+ * by containment, because since #719 EVERY absolute path is "inside" `/`.
90
+ *
91
+ * This is a floor, not an allow-list. The alternative the issue floated —
92
+ * requiring linked paths to sit under a configurable allowed root — is a bigger
93
+ * lever than the problem needs: it adds a config dimension and would invalidate
94
+ * every already-linked project the day it shipped. A denylist only ever refuses
95
+ * paths nobody legitimately links a project at.
96
+ *
97
+ * Deliberately NOT here: `/opt`, `/srv`, `/mnt`, `/tmp`, `/root` and `/var` —
98
+ * each is somewhere a real checkout or notebook plausibly lives (`/var` in
99
+ * particular holds Paddock's own data dir on some boxes, and macOS temp dirs
100
+ * canonicalise under `/private/var`).
101
+ *
102
+ * On most distros `/bin`, `/sbin` and `/lib*` are symlinks into `/usr`;
103
+ * {@link import("./projects.js").ProjectStore.validatePath} canonicalises before
104
+ * checking, so those are caught by the `/usr` entry too. They are listed anyway
105
+ * for the systems where they are real directories.
106
+ */
107
+ export declare const SYSTEM_PATH_DENYLIST: readonly ["/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/libx32", "/proc", "/run", "/sbin", "/sys", "/usr"];
108
+ /**
109
+ * Whether a (resolved) path is the filesystem root or sits inside a system
110
+ * directory — the floor beneath which {@link
111
+ * import("./projects.js").ProjectStore.validatePath} refuses to link a project
112
+ * (issue #720).
113
+ *
114
+ * This is a FOOTGUN guard, not a privilege boundary: anyone who can reach the
115
+ * projects API can already create projects and run turns as whoever Paddock runs
116
+ * as. It exists because the failure is silent and the blast radius is large — a
117
+ * linked project's directory becomes a keeper's cwd, that keeper runs
118
+ * `acceptEdits` by default, and a MANAGED project's `contentDir` IS that
119
+ * directory, so the sweeper writes `CLAUDE.md`/`CHANGELOG.md` into it.
120
+ */
121
+ export declare function isSystemPath(candidate: string): boolean;
77
122
  /**
78
123
  * Resolve a project's working directory (keeper cwd) from its metadata dir plus
79
124
  * whichever backing field is set:
@@ -79,12 +79,76 @@ export function repoCheckoutName(repo) {
79
79
  * (it does NOT follow symlinks — callers that care resolve them beforehand).
80
80
  *
81
81
  * The `+ path.sep` is load-bearing: a plain `startsWith(parent)` would call
82
- * `/data/projects-old` a child of `/data/projects`.
82
+ * `/data/projects-old` a child of `/data/projects`. It does NOT hold for the one
83
+ * parent whose string already ends in a separator — the filesystem root — where
84
+ * appending would ask for `"//"` and report every child as outside (issue #719).
85
+ * That made the bidirectional overlap guard in `validatePath` fail open in both
86
+ * directions once any project's working dir was `/`, so two keepers could be
87
+ * created with overlapping working trees. Append the separator only when the
88
+ * parent doesn't already end in one.
83
89
  */
84
90
  export function isPathInside(child, parent) {
85
91
  const c = path.resolve(child);
86
92
  const p = path.resolve(parent);
87
- return c === p || c.startsWith(p + path.sep);
93
+ return c === p || c.startsWith(p.endsWith(path.sep) ? p : p + path.sep);
94
+ }
95
+ /**
96
+ * Directories beneath which a project may never be linked (issue #720) — the
97
+ * operating system rather than anybody's work.
98
+ *
99
+ * Each entry denies itself and everything under it. The filesystem root is
100
+ * handled separately in {@link isSystemPath}: it is refused by exact match, not
101
+ * by containment, because since #719 EVERY absolute path is "inside" `/`.
102
+ *
103
+ * This is a floor, not an allow-list. The alternative the issue floated —
104
+ * requiring linked paths to sit under a configurable allowed root — is a bigger
105
+ * lever than the problem needs: it adds a config dimension and would invalidate
106
+ * every already-linked project the day it shipped. A denylist only ever refuses
107
+ * paths nobody legitimately links a project at.
108
+ *
109
+ * Deliberately NOT here: `/opt`, `/srv`, `/mnt`, `/tmp`, `/root` and `/var` —
110
+ * each is somewhere a real checkout or notebook plausibly lives (`/var` in
111
+ * particular holds Paddock's own data dir on some boxes, and macOS temp dirs
112
+ * canonicalise under `/private/var`).
113
+ *
114
+ * On most distros `/bin`, `/sbin` and `/lib*` are symlinks into `/usr`;
115
+ * {@link import("./projects.js").ProjectStore.validatePath} canonicalises before
116
+ * checking, so those are caught by the `/usr` entry too. They are listed anyway
117
+ * for the systems where they are real directories.
118
+ */
119
+ export const SYSTEM_PATH_DENYLIST = [
120
+ "/bin",
121
+ "/boot",
122
+ "/dev",
123
+ "/etc",
124
+ "/lib",
125
+ "/lib32",
126
+ "/lib64",
127
+ "/libx32",
128
+ "/proc",
129
+ "/run",
130
+ "/sbin",
131
+ "/sys",
132
+ "/usr",
133
+ ];
134
+ /**
135
+ * Whether a (resolved) path is the filesystem root or sits inside a system
136
+ * directory — the floor beneath which {@link
137
+ * import("./projects.js").ProjectStore.validatePath} refuses to link a project
138
+ * (issue #720).
139
+ *
140
+ * This is a FOOTGUN guard, not a privilege boundary: anyone who can reach the
141
+ * projects API can already create projects and run turns as whoever Paddock runs
142
+ * as. It exists because the failure is silent and the blast radius is large — a
143
+ * linked project's directory becomes a keeper's cwd, that keeper runs
144
+ * `acceptEdits` by default, and a MANAGED project's `contentDir` IS that
145
+ * directory, so the sweeper writes `CLAUDE.md`/`CHANGELOG.md` into it.
146
+ */
147
+ export function isSystemPath(candidate) {
148
+ const p = path.resolve(candidate);
149
+ if (p === path.parse(p).root)
150
+ return true;
151
+ return SYSTEM_PATH_DENYLIST.some((root) => isPathInside(p, root));
88
152
  }
89
153
  /**
90
154
  * Resolve a project's working directory (keeper cwd) from its metadata dir plus
@@ -412,3 +412,26 @@ export type UpdateProjectInput = Partial<Pick<ProjectYaml, "name" | "status" | "
412
412
  */
413
413
  curation?: CurationOverride | null;
414
414
  };
415
+ /**
416
+ * The plain metadata keys a PATCH may set — the RUNTIME half of
417
+ * {@link UpdateProjectInput} (issue #721).
418
+ *
419
+ * The type is a compile-time shape; the request body is not type-checked at all,
420
+ * so `update()` used to spread it straight into the record it persisted and any
421
+ * invented key landed in `project.yaml` verbatim. This list is what it filters
422
+ * against.
423
+ *
424
+ * It deliberately covers ONLY the blanket-spread fields. The tri-state overrides
425
+ * (`models`/`driveMode`/`maxSpawnDepth`/`hooksMcpEnabled`/`recovery`/
426
+ * `attachments`/`curation`) are destructured out and applied explicitly, because
427
+ * a spread cannot express "delete this field" — which is how an override is
428
+ * cleared back to inherit — so they are patchable without being listed here.
429
+ *
430
+ * Not here, and not patchable by any route: `slug`/`started` (identity),
431
+ * `pinned`/`triggers` (their own endpoints), and `path`/`managed`/`repo` — the
432
+ * three fields that decide the keeper's cwd (issues #206, #718).
433
+ *
434
+ * The `satisfies` clause is the drift guard: a key removed from `ProjectYaml`
435
+ * fails the build here rather than silently becoming un-patchable.
436
+ */
437
+ export declare const PATCHABLE_KEYS: readonly ["name", "status", "domain", "group", "visibility", "summary", "links", "model", "permissionMode", "maxTurns", "docker"];
@@ -46,3 +46,38 @@ export function normalizeLinks(raw) {
46
46
  }
47
47
  return out;
48
48
  }
49
+ /**
50
+ * The plain metadata keys a PATCH may set — the RUNTIME half of
51
+ * {@link UpdateProjectInput} (issue #721).
52
+ *
53
+ * The type is a compile-time shape; the request body is not type-checked at all,
54
+ * so `update()` used to spread it straight into the record it persisted and any
55
+ * invented key landed in `project.yaml` verbatim. This list is what it filters
56
+ * against.
57
+ *
58
+ * It deliberately covers ONLY the blanket-spread fields. The tri-state overrides
59
+ * (`models`/`driveMode`/`maxSpawnDepth`/`hooksMcpEnabled`/`recovery`/
60
+ * `attachments`/`curation`) are destructured out and applied explicitly, because
61
+ * a spread cannot express "delete this field" — which is how an override is
62
+ * cleared back to inherit — so they are patchable without being listed here.
63
+ *
64
+ * Not here, and not patchable by any route: `slug`/`started` (identity),
65
+ * `pinned`/`triggers` (their own endpoints), and `path`/`managed`/`repo` — the
66
+ * three fields that decide the keeper's cwd (issues #206, #718).
67
+ *
68
+ * The `satisfies` clause is the drift guard: a key removed from `ProjectYaml`
69
+ * fails the build here rather than silently becoming un-patchable.
70
+ */
71
+ export const PATCHABLE_KEYS = [
72
+ "name",
73
+ "status",
74
+ "domain",
75
+ "group",
76
+ "visibility",
77
+ "summary",
78
+ "links",
79
+ "model",
80
+ "permissionMode",
81
+ "maxTurns",
82
+ "docker",
83
+ ];
@@ -1,7 +1,7 @@
1
1
  import { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor } from "./project-mime.js";
2
2
  export { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor };
3
- import { ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError } from "./project-paths.js";
4
- export { ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
3
+ import { ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError } from "./project-paths.js";
4
+ export { ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
5
5
  import { type Project, type FileKind, type FileEntry, type CreateProjectInput, type UpdateProjectInput } from "./project-types.js";
6
6
  export * from "./project-types.js";
7
7
  export declare class ProjectStore {
@@ -112,6 +112,9 @@ export declare class ProjectStore {
112
112
  * - a relative path (a cwd must be unambiguous — it is baked into every
113
113
  * transcript path, and resolving it against the server's cwd is a footgun);
114
114
  * - a path that exists but isn't a directory;
115
+ * - the filesystem root, or a path inside a system directory (issue #720) —
116
+ * see {@link isSystemPath} for the floor and why it is a denylist rather
117
+ * than a configurable allowed root;
115
118
  * - a path inside `projectsRoot` or the data dir — that is Paddock's own
116
119
  * state, and pointing at it re-creates the nesting this feature exists to
117
120
  * avoid (plus `remove()` would then really delete it);
@@ -36,10 +36,10 @@ import { PROJECT_SCHEMA_VERSION, SCHEMA_VERSION_KEY, projectSchemaSkip, } from "
36
36
  // them at `./projects.js` — the same one-place-to-import discipline as before.
37
37
  import { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor, } from "./project-mime.js";
38
38
  export { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor };
39
- import { SLUG_RE, ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
40
- export { ROOT_KEY, isRootKey, isPathInside, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
39
+ import { SLUG_RE, ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
40
+ export { ROOT_KEY, isRootKey, isPathInside, isSystemPath, isManaged, contentDirFor, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
41
41
  import { gitRemotes, normalizeRemote } from "./adoptable.js";
42
- import { normalizeLinks, } from "./project-types.js";
42
+ import { normalizeLinks, PATCHABLE_KEYS, } from "./project-types.js";
43
43
  import * as projectFiles from "./project-files.js";
44
44
  // Barrel: re-export all type declarations + normalizeLinks + the config-type
45
45
  // re-exports (PaddockSchedule/PaddockHook/PaddockTrigger/RecoveryOverride/
@@ -221,7 +221,12 @@ export class ProjectStore {
221
221
  async rmInsideRoot(target) {
222
222
  const resolved = path.resolve(target);
223
223
  const root = path.resolve(this.root);
224
- if (resolved === root || !isPathInside(resolved, root)) {
224
+ // A projects root of `/` would make every path on the box "inside" it and
225
+ // turn this guard into a no-op. That was accidentally safe before #719 (when
226
+ // `isPathInside(x, "/")` was false for everything); now that containment
227
+ // reports the truth, refuse the degenerate root explicitly rather than
228
+ // relying on a bug to hold the line.
229
+ if (root === path.parse(root).root || resolved === root || !isPathInside(resolved, root)) {
225
230
  throw new ProjectError(`Refusing to delete outside the projects root: ${target}`, "invalid");
226
231
  }
227
232
  await fs.rm(resolved, { recursive: true, force: true });
@@ -259,6 +264,9 @@ export class ProjectStore {
259
264
  * - a relative path (a cwd must be unambiguous — it is baked into every
260
265
  * transcript path, and resolving it against the server's cwd is a footgun);
261
266
  * - a path that exists but isn't a directory;
267
+ * - the filesystem root, or a path inside a system directory (issue #720) —
268
+ * see {@link isSystemPath} for the floor and why it is a denylist rather
269
+ * than a configurable allowed root;
262
270
  * - a path inside `projectsRoot` or the data dir — that is Paddock's own
263
271
  * state, and pointing at it re-creates the nesting this feature exists to
264
272
  * avoid (plus `remove()` would then really delete it);
@@ -297,6 +305,18 @@ export class ProjectStore {
297
305
  throw new ProjectError(`Project path is not a directory: ${input}`, "not_directory");
298
306
  }
299
307
  }
308
+ // The system-path floor (issue #720), applied to BOTH spellings.
309
+ //
310
+ // The canonicalised path is the one that matters — a symlink pointing at
311
+ // `/etc` must be refused for where it really goes. But the path AS WRITTEN
312
+ // has to be checked too, because `/proc/self/cwd` canonicalises to an
313
+ // ordinary directory (Paddock's own checkout) and so passes the resolved
314
+ // check: a `/proc` path is process-relative and non-durable, which a cwd
315
+ // baked into every transcript path can never be.
316
+ if (isSystemPath(resolved) || isSystemPath(input)) {
317
+ throw new ProjectError(`Project path is a system directory and cannot back a project: ${input}` +
318
+ (resolved === path.resolve(input) ? "" : ` (resolves to ${resolved})`), "invalid");
319
+ }
300
320
  if (isPathInside(resolved, this.root)) {
301
321
  throw new ProjectError(`Project path must be outside the projects root (${this.root}): ${input}`, "invalid");
302
322
  }
@@ -699,30 +719,64 @@ export class ProjectStore {
699
719
  // can't express "delete this field", which is how an override is cleared back
700
720
  // to inherit.
701
721
  const { driveMode: driveModePatch, maxSpawnDepth: maxSpawnDepthPatch, hooksMcpEnabled: hooksMcpPatch, recovery: recoveryPatch, attachments: attachmentsPatch, curation: curationPatch, models: modelsPatch, ...rest } = patch;
722
+ // Take ONLY the allowlisted keys off the body (issue #721). `rest` is spread
723
+ // from an untrusted request body, so the previous `...rest` persisted any key
724
+ // a caller invented — verbatim, unbounded, into a file re-parsed on every
725
+ // project listing — and a key seeded with garbage today is a landmine for the
726
+ // release that starts honouring it.
727
+ const unknown = [];
728
+ const patched = {};
729
+ for (const [key, value] of Object.entries(rest)) {
730
+ if (value === undefined)
731
+ continue;
732
+ if (!PATCHABLE_KEYS.includes(key)) {
733
+ unknown.push(key);
734
+ continue;
735
+ }
736
+ patched[key] = value;
737
+ }
738
+ if (unknown.length > 0) {
739
+ this.log.warn(`Ignoring unpatchable key(s) in PATCH of project "${slug}": ${unknown.join(", ")}`);
740
+ }
702
741
  const next = {
703
742
  ...this.stripDto(current),
704
- ...rest,
743
+ ...patched,
705
744
  slug: current.slug, // immutable
706
745
  started: current.started, // immutable
707
746
  updated: today(),
708
747
  };
709
- // `path` and `managed` are immutable (issue #206), enforced here rather than
710
- // by their absence from UpdateProjectInput — that type is a compile-time shape
711
- // while `rest` is spread from a request body.
748
+ // `path`, `managed` and `repo` are immutable — the THREE fields that feed
749
+ // `workingDirFor()` and therefore decide the keeper's cwd. The allowlist above
750
+ // already keeps them out of `patched`; re-asserting them from `current` here
751
+ // is deliberate belt-and-braces at the point the record is built, so a key
752
+ // later added to the allowlist cannot silently move a cwd.
712
753
  //
713
- // For `path` both directions matter: a project must not be RE-POINTED (its cwd
714
- // is baked into every transcript path, so moving it strands the history), and
715
- // one must not be given a path by a stray key, which would silently hand its
716
- // keeper a cwd somewhere else on the box that never went through
717
- // `validatePath`. `managed` is re-asserted from `current` for the same reason
718
- // — flipping it would move where the curated trio lives and either start the
719
- // sweeper writing into a checkout or orphan the existing notes. (Changing it
720
- // deliberately is a migration, not a PATCH; see #708 for what a silent
721
- // location flip does to history.)
754
+ // For `path` both directions matter (issue #206): a project must not be
755
+ // RE-POINTED (its cwd is baked into every transcript path, so moving it
756
+ // strands the history), and one must not be GIVEN a path by a stray key,
757
+ // which would hand its keeper a cwd somewhere else on the box that never went
758
+ // through `validatePath`. `managed` is re-asserted for the same reason —
759
+ // flipping it moves where the curated trio lives and either starts the sweeper
760
+ // writing into a checkout or orphans the existing notes.
761
+ //
762
+ // `repo` was the one that got missed (issue #718). It is documented immutable
763
+ // (`ProjectYaml.repo`: "Set at creation; immutable thereafter"), `create()` and
764
+ // `promote()` both validate it with `isValidRepoUrl()`, and `update()` did
765
+ // neither: `PATCH {"repo":"not a url at all"}` returned 200 and relocated
766
+ // `workingDir` AND `contentDir` to `<dir>/not-a-url-at-all`, a directory that
767
+ // does not exist — every subsequent turn then hung 60s waiting for a session
768
+ // file and failed, with the existing chats stranded on the old cwd. Acquiring a
769
+ // repo for an existing project is what `promote()` is for; changing a backing
770
+ // store is a migration, not a PATCH (see #708 for what a silent location flip
771
+ // does to history).
722
772
  if (current.path)
723
773
  next.path = current.path;
724
774
  else
725
775
  delete next.path;
776
+ if (current.repo)
777
+ next.repo = current.repo;
778
+ else
779
+ delete next.repo;
726
780
  next.managed = current.managed;
727
781
  if (driveModePatch === null) {
728
782
  // Clear the per-project override -> inherit the global default (issue #122).
@@ -12,9 +12,22 @@ export interface QueuedPart {
12
12
  id: string;
13
13
  text: string;
14
14
  }
15
+ /**
16
+ * A composer attachment staged on the slot (#728) — the same lightweight ref the
17
+ * `chat:send` path carries. The bytes live in the AttachmentStore; only the id,
18
+ * filename and kind travel with the queued message.
19
+ */
20
+ export interface QueuedAttachment {
21
+ id: string;
22
+ filename: string;
23
+ kind?: string;
24
+ }
15
25
  /** A queued message awaiting auto-send. */
16
26
  export interface QueuedMessage {
17
- /** The slot's full text — what gets sent. */
27
+ /**
28
+ * The slot's full text — what gets sent. May be `""` when the slot holds only
29
+ * attachments (#728: an attachment-only message is valid, #328).
30
+ */
18
31
  text: string;
19
32
  /** SERVER-stamped enqueue time. Informational — never load-bearing (#736). */
20
33
  createdAtMs: number;
@@ -30,7 +43,22 @@ export interface QueuedMessage {
30
43
  * entry written by an older server; readers treat that as one legacy identity.
31
44
  */
32
45
  parts?: QueuedPart[];
46
+ /**
47
+ * Files staged in the composer that ride this message when it drains (#728).
48
+ * Merged as a UNION BY ID so a write can only ever add: a client re-asserting
49
+ * its queue after a reload has an empty tray by then, and must not be able to
50
+ * wipe the files off the shared slot.
51
+ */
52
+ attachments?: QueuedAttachment[];
33
53
  }
54
+ /**
55
+ * Union two attachment lists by id, preserving order and preferring the entry
56
+ * already on the slot (its filename/kind is the one every client has been shown).
57
+ * Bounded by {@link MAX_SLOT_ATTACHMENTS}.
58
+ */
59
+ export declare function unionAttachments(current: QueuedAttachment[] | undefined, incoming: QueuedAttachment[] | undefined): QueuedAttachment[];
60
+ /** Does this slot hold anything worth sending? Text, attachments, or both. */
61
+ export declare function hasQueuedContent(m: QueuedMessage | null | undefined): boolean;
34
62
  /** The identities of an entry, synthesising one for a legacy entry without any. */
35
63
  export declare function partsOf(m: QueuedMessage): QueuedPart[];
36
64
  export declare class QueuedMessageStore {
@@ -86,7 +114,9 @@ export declare class QueuedMessageStore {
86
114
  * `nowMs` stamps a fresh slot's `createdAtMs` SERVER-side; an existing slot keeps
87
115
  * the one it has. The client's clock never enters the store (#736).
88
116
  */
89
- upsert(agent: string, sessionId: string, incoming: QueuedPart, nowMs: number, mintVersion: () => string): Promise<QueuedMessage | null>;
117
+ upsert(agent: string, sessionId: string, incoming: QueuedPart & {
118
+ attachments?: QueuedAttachment[];
119
+ }, nowMs: number, mintVersion: () => string): Promise<QueuedMessage | null>;
90
120
  /** Write-through, serialised so overlapping writes can't corrupt the file. */
91
121
  private persist;
92
122
  }
@@ -49,6 +49,58 @@ function keyOf(agent, sessionId) {
49
49
  const MERGE_SEP = "\n";
50
50
  /** How many (id, text) identities one slot remembers. Bounds the sidecar entry. */
51
51
  const MAX_KNOWN = 24;
52
+ /**
53
+ * How many attachments one slot can hold (#728). The composer caps a single
54
+ * message at 10 by default, but the slot is shared and merges additively, so two
55
+ * devices could otherwise stack refs into the sidecar without bound. Well above
56
+ * any real queued follow-up; the excess is dropped rather than persisted.
57
+ */
58
+ const MAX_SLOT_ATTACHMENTS = 20;
59
+ /**
60
+ * Union two attachment lists by id, preserving order and preferring the entry
61
+ * already on the slot (its filename/kind is the one every client has been shown).
62
+ * Bounded by {@link MAX_SLOT_ATTACHMENTS}.
63
+ */
64
+ export function unionAttachments(current, incoming) {
65
+ const out = [];
66
+ const seen = new Set();
67
+ for (const a of [...(current ?? []), ...(incoming ?? [])]) {
68
+ if (!a || typeof a.id !== "string" || !a.id || seen.has(a.id))
69
+ continue;
70
+ seen.add(a.id);
71
+ out.push(a.kind === undefined ? { id: a.id, filename: a.filename } : { ...a });
72
+ if (out.length >= MAX_SLOT_ATTACHMENTS)
73
+ break;
74
+ }
75
+ return out;
76
+ }
77
+ /** Narrow a parsed value to a well-formed attachment list (dropping junk). */
78
+ function sanitizeAttachments(v) {
79
+ if (!Array.isArray(v))
80
+ return [];
81
+ const out = [];
82
+ for (const a of v) {
83
+ if (!a || typeof a !== "object")
84
+ continue;
85
+ const o = a;
86
+ if (typeof o.id !== "string" || !o.id)
87
+ continue;
88
+ if (typeof o.filename !== "string" || !o.filename)
89
+ continue;
90
+ out.push({
91
+ id: o.id,
92
+ filename: o.filename,
93
+ ...(typeof o.kind === "string" ? { kind: o.kind } : {}),
94
+ });
95
+ }
96
+ return out;
97
+ }
98
+ /** Does this slot hold anything worth sending? Text, attachments, or both. */
99
+ export function hasQueuedContent(m) {
100
+ if (!m)
101
+ return false;
102
+ return m.text.length > 0 || (m.attachments?.length ?? 0) > 0;
103
+ }
52
104
  /**
53
105
  * Fold a write from a client that ISN'T on the slot's current version into the
54
106
  * slot's text.
@@ -120,11 +172,16 @@ export class QueuedMessageStore {
120
172
  typeof p.text === "string")
121
173
  : [];
122
174
  const id = typeof v.id === "string" ? entry.id : undefined;
175
+ // Same tolerance for the staged attachments (#728): malformed entries
176
+ // are dropped, never thrown on — a corrupt sidecar must not be able to
177
+ // stop the queue loading.
178
+ const attachments = sanitizeAttachments(v.attachments);
123
179
  map.set(k, {
124
180
  text: entry.text,
125
181
  createdAtMs: entry.createdAtMs,
126
182
  ...(id ? { id } : {}),
127
183
  ...(parts.length > 0 ? { parts } : {}),
184
+ ...(attachments.length > 0 ? { attachments } : {}),
128
185
  });
129
186
  }
130
187
  }
@@ -208,29 +265,51 @@ export class QueuedMessageStore {
208
265
  const map = await this.ensureLoaded();
209
266
  const key = keyOf(agent, sessionId);
210
267
  const current = map.get(key) ?? null;
211
- if (!incoming.text.trim())
268
+ // Attachments merge additively and independently of the text (#728): a write
269
+ // can add files, never remove them. Computed first so an attachments-only
270
+ // write — a valid message with no prose (#328) — has something to write even
271
+ // though every text rule below leaves the text alone.
272
+ const attachments = unionAttachments(current?.attachments, incoming.attachments);
273
+ const gainedAttachments = attachments.length > (current?.attachments?.length ?? 0);
274
+ const hasText = incoming.text.trim().length > 0;
275
+ if (!hasText && !gainedAttachments)
212
276
  return current;
213
277
  const known = current ? partsOf(current) : [];
214
- // Already folded in under some identity ⇒ a re-assert, not new text.
215
- if (known.some((p) => p.text === incoming.text))
278
+ // Already folded in under some identity ⇒ a re-assert, not new text. Still
279
+ // worth writing if it brought new files with it.
280
+ if (hasText && known.some((p) => p.text === incoming.text) && !gainedAttachments) {
216
281
  return current;
282
+ }
283
+ const textIsNew = hasText && !known.some((p) => p.text === incoming.text);
217
284
  const text = !current
218
285
  ? incoming.text
219
- : incoming.id === current.id
220
- ? // The client can see the slot as it stands, so its text IS the slot.
221
- incoming.text
222
- : mergeInto(current.text, known, incoming.text);
286
+ : !textIsNew
287
+ ? // Nothing new to say — an attachments-only write, or a re-assert that
288
+ // brought files. The slot's prose is untouched.
289
+ current.text
290
+ : incoming.id === current.id
291
+ ? // The client can see the slot as it stands, so its text IS the slot.
292
+ incoming.text
293
+ : mergeInto(current.text, known, incoming.text);
223
294
  const version = mintVersion();
224
295
  // Remember BOTH identities this write creates: what the client sent (which it
225
296
  // will re-assert on reload) and the version we hand back (which it will
226
297
  // re-assert once it adopts the broadcast). Either one reaching a later drain
227
298
  // has to be recognisable as already-flushed.
228
- const parts = [...known, { id: incoming.id, text: incoming.text }, { id: version, text }].slice(-MAX_KNOWN);
299
+ // An attachments-only write contributes no text, so it contributes no identity
300
+ // to remember either — an empty-text part would match nothing and only crowd
301
+ // the ledger. The slot version is always recorded.
302
+ const parts = [
303
+ ...known,
304
+ ...(hasText ? [{ id: incoming.id, text: incoming.text }] : []),
305
+ { id: version, text },
306
+ ].slice(-MAX_KNOWN);
229
307
  const next = {
230
308
  text,
231
309
  createdAtMs: current?.createdAtMs ?? nowMs,
232
310
  id: version,
233
311
  parts,
312
+ ...(attachments.length > 0 ? { attachments } : {}),
234
313
  };
235
314
  map.set(key, next);
236
315
  await this.persist(map);
@@ -134,14 +134,17 @@ export function registerChatWorkspaceRoutes(app, ctx) {
134
134
  *
135
135
  * `running` is authoritative (the live hub), not inferred from timestamps.
136
136
  * `unread` reuses the sidebar badge's derivation — the manual override (#458)
137
- * or a completed turn newer than the read watermark (#189) — with ONE
138
- * deliberate difference: archived chats are filed away on purpose, so they
139
- * are excluded here. That means an archived chat with an unread turn still
140
- * counts toward the sidebar's badge (`buildChatTurns` never consults
141
- * `ArchiveStore`) while not appearing in this list. The two can therefore
142
- * disagree by exactly that set; making them agree means teaching the badge
143
- * about archiving, which is a behaviour change to the badge, not to this
144
- * route.
137
+ * or a completed turn newer than the read watermark (#189) — INCLUDING the
138
+ * archived exclusion below.
139
+ *
140
+ * That last part used to be this route's alone: the badge never consulted
141
+ * `ArchiveStore`, so an archived unread chat counted toward the sidebar while
142
+ * being absent from this feed, and the two surfaces disagreed by exactly that
143
+ * set. #732 settled it the way this route already read it — archiving is the
144
+ * user filing a chat away, so it silences both — and `buildChatTurns` now
145
+ * applies the same rule. Keep the two in step: read state was made
146
+ * server-authoritative (#488) so surfaces could not diverge, and a rule that
147
+ * lives in only one of them is that divergence again.
145
148
  */
146
149
  app.get("/chats/attention", {
147
150
  schema: {
@@ -632,6 +635,13 @@ export function registerChatWorkspaceRoutes(app, ctx) {
632
635
  }
633
636
  await cleanupAttachments(agent, req.params.sessionId);
634
637
  const { removed, retained } = await herdctl.deleteSession(agent, req.params.sessionId);
638
+ // #732: and the chat's herdctl job records, which are what feeds the
639
+ // sidebar unread badge. Left behind, they kept the badge counting a chat
640
+ // there was nothing left to open to clear. Only when the transcript was
641
+ // actually REMOVED — see `purgeSessionJobs` for why a `retained`
642
+ // (released) chat keeps its records.
643
+ if (removed)
644
+ await herdctl.purgeSessionJobs([req.params.sessionId]);
635
645
  // Drop any archived/starred/unread/detached flag so a future session id
636
646
  // can't inherit it. (Read-state watermarks, other users' unread overrides,
637
647
  // provenance and any queued message are deliberately NOT swept here — that
@@ -1248,6 +1258,10 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1248
1258
  const user = readStateUser(req);
1249
1259
  const removed = [];
1250
1260
  const failed = [];
1261
+ // The subset whose transcript was genuinely deleted — the ids whose job
1262
+ // records may be purged (#732). Narrower than `removed`, which also
1263
+ // holds RELEASED chats: those are still listed, so their records stay.
1264
+ const deleted = [];
1251
1265
  for (const sessionId of sessionIds) {
1252
1266
  try {
1253
1267
  // #731: same interlock as the single delete, per id. A chat whose
@@ -1270,11 +1284,16 @@ export function registerChatWorkspaceRoutes(app, ctx) {
1270
1284
  removed.push(sessionId);
1271
1285
  else
1272
1286
  failed.push(sessionId);
1287
+ if (gone.removed)
1288
+ deleted.push(sessionId);
1273
1289
  }
1274
1290
  catch {
1275
1291
  failed.push(sessionId);
1276
1292
  }
1277
1293
  }
1294
+ // ONE jobs-dir pass for the whole batch (#732) — purging per chat would
1295
+ // re-scan the shared directory once per id, up to 500 times.
1296
+ await herdctl.purgeSessionJobs(deleted);
1278
1297
  // Drop the archived/starred/unread/detached flags of the chats that were
1279
1298
  // ACTUALLY removed, so a recycled session id can't inherit stale state.
1280
1299
  //