@mnstry/atelier 0.2.0-alpha.4 → 0.2.0-alpha.5

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 (53) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +38 -12
  3. package/contracts/public-api-baseline.json +57 -0
  4. package/docs/assurance-controls.md +39 -0
  5. package/docs/atelier-runtime.md +15 -0
  6. package/docs/blocks/claims.md +15 -9
  7. package/docs/design.md +12 -6
  8. package/docs/install.md +26 -4
  9. package/docs/knowledge-graph.md +8 -4
  10. package/docs/local-services.md +101 -0
  11. package/docs/release-engineering.md +75 -10
  12. package/docs/repo-boundary-guard.md +12 -2
  13. package/docs/upgrade.md +25 -2
  14. package/fixtures/projects/sample-workspace/content/source.html.kg.json +4 -1
  15. package/fixtures/projects/source-formats-workspace/content/data.json.kg.json +4 -1
  16. package/fixtures/projects/source-formats-workspace/content/logo.png.kg.json +4 -1
  17. package/fixtures/projects/source-formats-workspace/content/metrics.csv.kg.json +4 -1
  18. package/fixtures/projects/source-formats-workspace/content/pipeline.yaml.kg.json +4 -1
  19. package/package.json +12 -5
  20. package/skills/claude/atelier-local-service/SKILL.md +47 -0
  21. package/skills/claude/atelier-public-boundary/SKILL.md +31 -0
  22. package/skills/codex/atelier-local-service/SKILL.md +47 -0
  23. package/skills/codex/atelier-public-boundary/SKILL.md +31 -0
  24. package/src/boundary/content-rules.mjs +278 -20
  25. package/src/boundary/policy.mjs +150 -60
  26. package/src/cli/execute-command.mjs +36 -0
  27. package/src/cli/run.mjs +17 -7
  28. package/src/collaboration/event-ledger.mjs +365 -0
  29. package/src/collaboration/index.mjs +17 -0
  30. package/src/collaboration/proposals.mjs +265 -65
  31. package/src/commands/attestation.mjs +20 -6
  32. package/src/commands/disclosure.mjs +133 -0
  33. package/src/commands/distribution.mjs +2 -1
  34. package/src/commands/extension-pack.mjs +2 -1
  35. package/src/commands/init.mjs +2 -1
  36. package/src/commands/server.mjs +1 -4
  37. package/src/disclosure/content-scan.mjs +193 -0
  38. package/src/egress/check.mjs +7 -38
  39. package/src/egress/forbidden-egress.mjs +32 -18
  40. package/src/graph/graph.mjs +112 -314
  41. package/src/graph/knowledge-graph.mjs +94 -18
  42. package/src/harness/context-client.mjs +9 -1
  43. package/src/index.mjs +12 -0
  44. package/src/project/config.mjs +66 -7
  45. package/src/project/file-class.mjs +14 -0
  46. package/src/project/package-root.mjs +10 -0
  47. package/src/project/path-match.mjs +38 -15
  48. package/src/project/private-state.mjs +110 -0
  49. package/src/server/local-sidecar.mjs +81 -59
  50. package/src/server/security.mjs +89 -4
  51. package/src/server/server.mjs +3 -2
  52. package/src/support/feedback-report.mjs +4 -3
  53. package/src/upgrade/upgrade.mjs +2 -1
@@ -5,9 +5,17 @@ import fs from 'node:fs'
5
5
  import http from 'node:http'
6
6
  import path from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
+ import {
9
+ atomicReplacePrivateText,
10
+ ensureContainedPrivateDirectory,
11
+ readRegularTextNoFollow,
12
+ } from '../project/private-state.mjs'
8
13
  import {
9
14
  expectedOriginForRequest,
10
15
  htmlDocumentHeaders,
16
+ isLoopbackHost,
17
+ loadPublishedWorkspaceManifest,
18
+ publishedStaticPathVerdict,
11
19
  requestHeader,
12
20
  resolveWorkspacePath,
13
21
  staticHeaders,
@@ -43,39 +51,37 @@ function nowMs() {
43
51
 
44
52
  function readJsonFile(file, fallback) {
45
53
  try {
46
- return JSON.parse(fs.readFileSync(file, 'utf8'))
54
+ return JSON.parse(readRegularTextNoFollow(file))
47
55
  } catch {
48
56
  return fallback
49
57
  }
50
58
  }
51
59
 
52
60
  function secureWriteJson(file, payload, mode = 0o600) {
53
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
54
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
55
- fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, { mode })
56
- fs.renameSync(tmp, file)
57
- try {
58
- fs.chmodSync(file, mode)
59
- } catch {
60
- // Best effort on filesystems that do not support chmod.
61
- }
61
+ atomicReplacePrivateText(file, `${JSON.stringify(payload, null, 2)}\n`, mode)
62
62
  }
63
63
 
64
64
  function readOrCreateNonce(noncePath) {
65
- const existing = fs.existsSync(noncePath) ? fs.readFileSync(noncePath, 'utf8').trim() : ''
65
+ let existing = ''
66
+ if (fs.existsSync(noncePath)) {
67
+ const stat = fs.lstatSync(noncePath)
68
+ if (!stat.isFile()) throw new Error('nonce state leaf is not a regular file')
69
+ existing = readRegularTextNoFollow(noncePath).trim()
70
+ }
66
71
  if (/^[a-f0-9]{64}$/.test(existing)) return existing
67
72
  const nonce = crypto.randomBytes(32).toString('hex')
68
- fs.writeFileSync(noncePath, `${nonce}\n`, { mode: 0o600 })
73
+ atomicReplacePrivateText(noncePath, `${nonce}\n`)
69
74
  return nonce
70
75
  }
71
76
 
72
- function json(res, code, obj) {
77
+ function json(res, code, obj, headers = {}) {
73
78
  const body = Buffer.from(`${JSON.stringify(obj, null, 2)}\n`)
74
79
  res.writeHead(code, {
75
80
  'Content-Type': 'application/json; charset=utf-8',
76
81
  'Content-Length': String(body.length),
77
82
  'Cache-Control': 'no-store',
78
83
  'X-Content-Type-Options': 'nosniff',
84
+ ...headers,
79
85
  })
80
86
  res.end(body)
81
87
  }
@@ -205,16 +211,33 @@ export function createAtelierSidecarServer({
205
211
  } = {}) {
206
212
  const root = path.resolve(workspaceRoot)
207
213
  const rootReal = fs.realpathSync(root)
214
+ const stateDirReal = ensureContainedPrivateDirectory({
215
+ workspaceRoot: root,
216
+ directory: stateDir,
217
+ label: 'Atelier state directory',
218
+ })
219
+ const publication = loadPublishedWorkspaceManifest(root)
208
220
  const workspaceId = workspaceIdForRoot(root)
209
- const noncePath = path.join(stateDir, '.atelier-nonce')
210
- const presencePath = path.join(stateDir, '.atelier-presence.json')
221
+ const noncePath = path.join(stateDirReal, '.atelier-nonce')
222
+ const presencePath = path.join(stateDirReal, '.atelier-presence.json')
211
223
  const mutationNonce = readOrCreateNonce(noncePath)
212
224
  const proposals = createProposalStore({
213
225
  workspaceRoot: root,
214
- proposalsDir: path.join(stateDir, '.atelier-proposals'),
226
+ proposalsDir: path.join(stateDirReal, '.atelier-proposals'),
215
227
  workspaceId,
216
228
  })
217
229
 
230
+ function resolvePublishedPath({ rel, requireFile = false, requireHtml = false } = {}) {
231
+ const resolved = resolveWorkspacePath({
232
+ workspaceRoot: root,
233
+ workspaceRootReal: rootReal,
234
+ rel,
235
+ requireFile,
236
+ requireHtml,
237
+ })
238
+ return publishedStaticPathVerdict({ resolved, publication })
239
+ }
240
+
218
241
  function readPresence() {
219
242
  const presence = readJsonFile(presencePath, defaultPresence())
220
243
  return {
@@ -229,12 +252,7 @@ export function createAtelierSidecarServer({
229
252
  }
230
253
 
231
254
  function recordView(rel, hints = {}) {
232
- const resolved = resolveWorkspacePath({
233
- workspaceRoot: root,
234
- workspaceRootReal: rootReal,
235
- rel,
236
- requireHtml: true,
237
- })
255
+ const resolved = resolvePublishedPath({ rel, requireHtml: true })
238
256
  if (!resolved.ok) return { ok: false, error: 'unknown workspace html file' }
239
257
  const entry = {
240
258
  sessionId: cleanIdentity(hints.sessionId, 120),
@@ -295,12 +313,7 @@ export function createAtelierSidecarServer({
295
313
  const viewId = cleanIdentity(url.searchParams.get('viewId'), 120)
296
314
  const rel = cleanIdentity(url.searchParams.get('path') || 'index.html', 500)
297
315
  const expectedWorkspaceId = cleanIdentity(url.searchParams.get('expectedWorkspaceId'), 120)
298
- const resolved = resolveWorkspacePath({
299
- workspaceRoot: root,
300
- workspaceRootReal: rootReal,
301
- rel,
302
- requireHtml: true,
303
- })
316
+ const resolved = resolvePublishedPath({ rel, requireHtml: true })
304
317
  const current = resolveCurrentContext({ sessionId })
305
318
  const workspaceVerified = Boolean(expectedWorkspaceId && expectedWorkspaceId === workspaceId)
306
319
  const caps = capabilityContract()
@@ -366,12 +379,7 @@ export function createAtelierSidecarServer({
366
379
  }
367
380
 
368
381
  if (url.pathname === '/api/proposals') {
369
- const resolved = resolveWorkspacePath({
370
- workspaceRoot: root,
371
- workspaceRootReal: rootReal,
372
- rel: body.path || body.rel,
373
- requireHtml: true,
374
- })
382
+ const resolved = resolvePublishedPath({ rel: body.path || body.rel, requireHtml: true })
375
383
  if (!resolved.ok) {
376
384
  json(res, 200, { ok: false, error: 'unknown workspace html file' })
377
385
  return
@@ -392,6 +400,7 @@ export function createAtelierSidecarServer({
392
400
  schema: result.record.schema,
393
401
  workspaceId,
394
402
  proposal: result.record.proposal,
403
+ diagnostics: result.diagnostics,
395
404
  })
396
405
  return
397
406
  }
@@ -416,6 +425,7 @@ export function createAtelierSidecarServer({
416
425
  proposal: result.record.proposal,
417
426
  diff: result.record.diff,
418
427
  copyable: result.record.copyable,
428
+ diagnostics: result.diagnostics,
419
429
  })
420
430
  return
421
431
  }
@@ -430,12 +440,7 @@ export function createAtelierSidecarServer({
430
440
  }
431
441
 
432
442
  const rel = url.pathname === '/' ? 'index.html' : url.pathname.slice(1)
433
- const resolved = resolveWorkspacePath({
434
- workspaceRoot: root,
435
- workspaceRootReal: rootReal,
436
- rel,
437
- requireFile: true,
438
- })
443
+ const resolved = resolvePublishedPath({ rel, requireFile: true })
439
444
  if (!resolved.ok) {
440
445
  text(res, resolved.status || 403, `${resolved.error || 'forbidden'}\n`)
441
446
  return
@@ -463,6 +468,16 @@ export function createAtelierSidecarServer({
463
468
  return
464
469
  }
465
470
 
471
+ if (!['GET', 'HEAD', 'POST'].includes(req.method || '')) {
472
+ json(res, 405, { ok: false, error: 'method not allowed' }, { Allow: 'GET, HEAD, POST' })
473
+ return
474
+ }
475
+
476
+ if (!trustedReadRequest(req.headers, { expectedOrigin })) {
477
+ json(res, 403, { ok: false, error: 'cross-origin or unclassified request refused' })
478
+ return
479
+ }
480
+
466
481
  if (req.method === 'POST') {
467
482
  await handlePost(req, res, url)
468
483
  return
@@ -478,12 +493,7 @@ export function createAtelierSidecarServer({
478
493
  return
479
494
  }
480
495
  const rel = url.searchParams.get('path') || 'index.html'
481
- const resolved = resolveWorkspacePath({
482
- workspaceRoot: root,
483
- workspaceRootReal: rootReal,
484
- rel,
485
- requireHtml: true,
486
- })
496
+ const resolved = resolvePublishedPath({ rel, requireHtml: true })
487
497
  if (!resolved.ok) {
488
498
  json(res, 404, { ok: false, error: 'unknown workspace html file' })
489
499
  return
@@ -509,12 +519,7 @@ export function createAtelierSidecarServer({
509
519
  return
510
520
  }
511
521
  if (url.pathname === '/api/resolve') {
512
- const resolved = resolveWorkspacePath({
513
- workspaceRoot: root,
514
- workspaceRootReal: rootReal,
515
- rel: url.searchParams.get('path') || 'index.html',
516
- requireHtml: true,
517
- })
522
+ const resolved = resolvePublishedPath({ rel: url.searchParams.get('path') || 'index.html', requireHtml: true })
518
523
  json(res, resolved.ok ? 200 : (resolved.status || 404), {
519
524
  ok: resolved.ok,
520
525
  schema: ATELIER_RESOLVE_SCHEMA,
@@ -533,37 +538,49 @@ export function createAtelierSidecarServer({
533
538
  return
534
539
  }
535
540
  if (url.pathname === '/api/proposals') {
541
+ const listed = proposals.listProposals()
542
+ if (!listed.ok) {
543
+ json(res, listed.status, { ok: false, error: listed.error, diagnostics: listed.diagnostics })
544
+ return
545
+ }
536
546
  json(res, 200, {
537
547
  ok: true,
538
548
  schema: 'atelier-proposals@v1',
539
549
  workspaceId,
540
- proposals: proposals.listProposals(),
550
+ proposals: listed.proposals,
541
551
  })
542
552
  return
543
553
  }
544
554
  const proposalReadMatch = url.pathname.match(/^\/api\/proposals\/([^/]+)$/)
545
555
  if (proposalReadMatch) {
546
- const record = proposals.readProposal(proposalReadMatch[1])
547
- json(res, record ? 200 : 404, record ? {
556
+ const result = proposals.readProposal(proposalReadMatch[1])
557
+ const record = result.record
558
+ json(res, result.ok ? 200 : result.status, result.ok ? {
548
559
  ok: true,
549
560
  schema: record.schema,
550
561
  workspaceId,
551
562
  proposal: record.proposal,
552
563
  diff: record.diff,
553
564
  copyable: record.copyable,
554
- } : { ok: false, error: 'proposal not found' })
565
+ } : { ok: false, error: result.error, diagnostics: result.diagnostics })
555
566
  return
556
567
  }
557
568
  if (url.pathname === '/proposals') {
558
- const body = renderProposalListPageHtml(proposals.listProposals())
569
+ const listed = proposals.listProposals()
570
+ if (!listed.ok) {
571
+ text(res, listed.status, `${listed.error}\n`)
572
+ return
573
+ }
574
+ const body = renderProposalListPageHtml(listed.proposals)
559
575
  res.writeHead(200, htmlDocumentHeaders(Buffer.byteLength(body)))
560
576
  res.end(body)
561
577
  return
562
578
  }
563
579
  const proposalPageMatch = url.pathname.match(/^\/proposals\/([^/]+)$/)
564
580
  if (proposalPageMatch) {
565
- const body = renderProposalDetailPageHtml(proposals.readProposal(proposalPageMatch[1]))
566
- res.writeHead(200, htmlDocumentHeaders(Buffer.byteLength(body)))
581
+ const result = proposals.readProposal(proposalPageMatch[1])
582
+ const body = renderProposalDetailPageHtml(result.record)
583
+ res.writeHead(result.ok ? 200 : result.status, htmlDocumentHeaders(Buffer.byteLength(body)))
567
584
  res.end(body)
568
585
  return
569
586
  }
@@ -595,8 +612,12 @@ export function createAtelierSidecarServer({
595
612
  workspaceRootReal: rootReal,
596
613
  workspaceId,
597
614
  mutationNonce,
615
+ publication,
598
616
  proposals,
599
617
  listen(listenPort = port, host = '127.0.0.1') {
618
+ if (!isLoopbackHost(host)) {
619
+ return Promise.reject(new Error(`non-loopback listen host refused: ${host}`))
620
+ }
600
621
  // A busy port is an ordinary condition, not a crash: surface it as a
601
622
  // rejection the CLI can print, instead of an unhandled 'error' event.
602
623
  return new Promise((resolve, reject) => {
@@ -615,6 +636,7 @@ export function createAtelierSidecarServer({
615
636
  })
616
637
  },
617
638
  close() {
639
+ if (!server.listening) return Promise.resolve()
618
640
  return new Promise((resolve, reject) => {
619
641
  server.close((error) => {
620
642
  if (error) reject(error)
@@ -37,6 +37,9 @@ export const LOOPBACK_HOSTS = new Set([
37
37
  '[::1]',
38
38
  ])
39
39
 
40
+ export const ATELIER_MANIFEST_SCHEMA = 'mnstry.atelier-manifest@v1'
41
+ export const PUBLISHED_STATIC_EXTENSIONS = new Set(['.html', '.js', '.mjs', '.css', '.svg', '.png', '.jpg', '.jpeg', '.webp'])
42
+
40
43
  export function parseHostHeader(value) {
41
44
  const raw = String(value || '').trim().toLowerCase()
42
45
  if (!raw) return ''
@@ -66,7 +69,7 @@ export function expectedOriginForRequest(headers, fallbackPort = null) {
66
69
  }
67
70
 
68
71
  export function sameOrigin(origin, expectedOrigin) {
69
- if (!origin) return true
72
+ if (!origin) return false
70
73
  if (!expectedOrigin) return false
71
74
  try {
72
75
  return new URL(origin).origin === new URL(expectedOrigin).origin
@@ -77,7 +80,7 @@ export function sameOrigin(origin, expectedOrigin) {
77
80
 
78
81
  export function trustedFetchSite(headers) {
79
82
  const value = requestHeader(headers, 'sec-fetch-site').toLowerCase()
80
- return value === '' || value === 'none' || value === 'same-origin' || value === 'same-site'
83
+ return value === 'none' || value === 'same-origin'
81
84
  }
82
85
 
83
86
  export function trustedHost(headers) {
@@ -85,11 +88,92 @@ export function trustedHost(headers) {
85
88
  }
86
89
 
87
90
  export function trustedReadRequest(headers, { expectedOrigin = expectedOriginForRequest(headers) } = {}) {
88
- return trustedHost(headers) && sameOrigin(requestHeader(headers, 'origin'), expectedOrigin)
91
+ const origin = requestHeader(headers, 'origin')
92
+ return trustedHost(headers) && trustedFetchSite(headers) && (!origin || sameOrigin(origin, expectedOrigin))
89
93
  }
90
94
 
91
95
  export function trustedMutationRequest(headers, { expectedOrigin = expectedOriginForRequest(headers) } = {}) {
92
- return trustedReadRequest(headers, { expectedOrigin }) && trustedFetchSite(headers)
96
+ const origin = requestHeader(headers, 'origin')
97
+ return Boolean(origin) && trustedReadRequest(headers, { expectedOrigin }) && sameOrigin(origin, expectedOrigin)
98
+ }
99
+
100
+ function manifestPathValue(value) {
101
+ if (typeof value === 'string') return value
102
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return ''
103
+ return value.path || value.output || value.href || ''
104
+ }
105
+
106
+ function secretShapedPath(value) {
107
+ return /(?:^|[._-])(?:secret|credential|token|password|nonce|private[._-]?key)(?:$|[._-])/i.test(value)
108
+ || /(?:^|\/)\.env(?:\.|$)/i.test(value)
109
+ }
110
+
111
+ function addManifestPath(paths, value, label) {
112
+ const candidate = manifestPathValue(value)
113
+ const normalized = normalizeWorkspacePath(candidate, { defaultFile: '' })
114
+ if (!candidate || !normalized.ok || /^[a-z][a-z0-9+.-]*:/i.test(candidate)) {
115
+ throw new Error(`atelier.manifest.json ${label} must be a workspace-relative path`)
116
+ }
117
+ if (deniedStaticStatePath(normalized.path) || normalized.path.split('/').some((segment) => segment.startsWith('.')) || secretShapedPath(normalized.path)) {
118
+ throw new Error(`atelier.manifest.json ${label} enrolls a hidden, state, or secret-shaped path`)
119
+ }
120
+ if (!PUBLISHED_STATIC_EXTENSIONS.has(path.extname(normalized.path).toLowerCase())) {
121
+ throw new Error(`atelier.manifest.json ${label} enrolls an unsupported static file type`)
122
+ }
123
+ paths.add(normalized.path)
124
+ }
125
+
126
+ export function loadPublishedWorkspaceManifest(workspaceRoot) {
127
+ const root = path.resolve(workspaceRoot)
128
+ const rootReal = fs.realpathSync(root)
129
+ const manifestPath = path.join(root, 'atelier.manifest.json')
130
+ if (!fs.existsSync(manifestPath)) throw new Error('served workspace must contain generated atelier.manifest.json')
131
+ const manifestReal = fs.realpathSync(manifestPath)
132
+ if (!pathContainedBy(rootReal, manifestReal)) throw new Error('atelier.manifest.json realpath escapes served workspace')
133
+ let manifest
134
+ try {
135
+ manifest = JSON.parse(fs.readFileSync(manifestReal, 'utf8'))
136
+ } catch (error) {
137
+ throw new Error(`atelier.manifest.json must be valid JSON: ${error.message}`)
138
+ }
139
+ if (manifest?.schema !== ATELIER_MANIFEST_SCHEMA) {
140
+ throw new Error(`atelier.manifest.json schema must be ${ATELIER_MANIFEST_SCHEMA}`)
141
+ }
142
+ const paths = new Set()
143
+ if (typeof manifest.entry !== 'string') throw new Error('atelier.manifest.json entry must be a workspace-relative HTML path')
144
+ addManifestPath(paths, manifest.entry, 'entry')
145
+ if (path.extname(manifest.entry).toLowerCase() !== '.html') throw new Error('atelier.manifest.json entry must be an HTML file')
146
+ for (const key of ['files', 'assets']) {
147
+ if (manifest[key] == null) continue
148
+ if (!Array.isArray(manifest[key])) throw new Error(`atelier.manifest.json ${key} must be an array`)
149
+ for (const [index, value] of manifest[key].entries()) addManifestPath(paths, value, `${key}[${index}]`)
150
+ }
151
+ for (const rel of paths) {
152
+ const resolved = resolveWorkspacePath({ workspaceRoot: root, workspaceRootReal: rootReal, rel, requireFile: true })
153
+ if (!resolved.ok) throw new Error(`atelier.manifest.json enrolled path is unavailable: ${rel}`)
154
+ const realRel = path.relative(rootReal, resolved.real).replaceAll('\\', '/')
155
+ if (realRel !== rel) throw new Error(`atelier.manifest.json enrolled path must not be a symlink: ${rel}`)
156
+ }
157
+ return Object.freeze({ manifest, manifestPath: manifestReal, paths })
158
+ }
159
+
160
+ export function publishedStaticPathVerdict({ resolved, publication } = {}) {
161
+ if (!resolved?.ok) return resolved
162
+ const realRel = path.relative(resolved.workspaceRootReal, resolved.real).replaceAll('\\', '/')
163
+ const segments = realRel.split('/')
164
+ if (segments.some((segment) => segment.startsWith('.')) || deniedStaticStatePath(realRel)) {
165
+ return { ok: false, status: 403, error: 'hidden or local state path is not published', path: resolved.path }
166
+ }
167
+ if (realRel === 'atelier.manifest.json' || secretShapedPath(realRel)) {
168
+ return { ok: false, status: 403, error: 'secret-shaped path is not published', path: resolved.path }
169
+ }
170
+ if (!PUBLISHED_STATIC_EXTENSIONS.has(path.extname(realRel).toLowerCase())) {
171
+ return { ok: false, status: 403, error: 'static file type is not published', path: resolved.path }
172
+ }
173
+ if (!publication?.paths?.has(realRel)) {
174
+ return { ok: false, status: 404, error: 'path is not enrolled by atelier.manifest.json', path: resolved.path }
175
+ }
176
+ return resolved
93
177
  }
94
178
 
95
179
  export function pathContainedBy(root, candidate) {
@@ -191,6 +275,7 @@ export function resolveWorkspacePath({
191
275
  real,
192
276
  stat,
193
277
  workspaceContained: true,
278
+ workspaceRootReal,
194
279
  }
195
280
  }
196
281
 
@@ -26,11 +26,12 @@ export async function runServerCommand(argv = process.argv.slice(2)) {
26
26
  if (args.smoke) {
27
27
  const address = await sidecar.listen()
28
28
  const base = `http://127.0.0.1:${address.port}`
29
+ const headers = { Origin: base, 'Sec-Fetch-Site': 'same-origin' }
29
30
  // @atelier-egress-local-computed
30
- const health = await fetch(`${base}/api/health`).then((res) => res.json())
31
+ const health = await fetch(`${base}/api/health`, { headers }).then((res) => res.json())
31
32
  if (!health.ok) throw new Error('health check failed')
32
33
  // @atelier-egress-local-computed
33
- const page = await fetch(`${base}/index.html`).then((res) => res.text())
34
+ const page = await fetch(`${base}/`, { headers }).then((res) => res.text())
34
35
  if (!page.includes('<meta name="mnstry:atelier"')) throw new Error('projection smoke failed')
35
36
  await sidecar.close()
36
37
  console.log('[atelier:browser:smoke] local projection and health endpoint passed')
@@ -56,7 +56,7 @@ const USAGE = `Usage: atelier feedback <subcommand>
56
56
  Subcommands:
57
57
  create --message TEXT | --message-file PATH [--context FILE] [--include-gates]
58
58
  Assemble a local feedback report from your own words and write it to
59
- ${LOCAL_STATE_DIR}/feedback/<hash>.json (mode 0600). This is the
59
+ ${LOCAL_STATE_DIR}/feedback/<hash>.json (mode 0600 on POSIX). This is the
60
60
  default subcommand. The report is first walked with the support-bundle
61
61
  banned key and value patterns; if anything matches, nothing is written
62
62
  and the refusal names the pattern label and location only.
@@ -202,8 +202,9 @@ export function buildFeedbackReport({
202
202
  }
203
203
 
204
204
  export function writeFeedbackReport(payload, { baseDir = process.cwd() } = {}) {
205
- const relative = path.join(LOCAL_STATE_DIR, 'feedback', `${feedbackReportHash(payload)}.json`)
206
- const file = path.join(baseDir, relative)
205
+ const nativeRelative = path.join(LOCAL_STATE_DIR, 'feedback', `${feedbackReportHash(payload)}.json`)
206
+ const relative = nativeRelative.split(path.sep).join('/')
207
+ const file = path.join(baseDir, nativeRelative)
207
208
  // "Local state" is only local if the directory it lands in is ignored, and
208
209
  // feedback writes wherever the user happens to stand rather than in a
209
210
  // configured workspace. Same git check-ignore test the project chassis runs
@@ -14,6 +14,7 @@ import {
14
14
  resolvePathValue,
15
15
  writeJson,
16
16
  } from '../project/config.mjs'
17
+ import { packageRootFrom } from '../project/package-root.mjs'
17
18
  import {
18
19
  BOUNDARY_POLICY_SCHEMA,
19
20
  checkBoundaryPolicy,
@@ -36,7 +37,7 @@ export const MIGRATION_CLASSES = new Set([
36
37
  'breaking',
37
38
  ])
38
39
 
39
- const packageRoot = path.resolve(new URL('../..', import.meta.url).pathname)
40
+ const packageRoot = packageRootFrom(import.meta.url)
40
41
  const packageJson = readJson(path.join(packageRoot, 'package.json'))
41
42
  const lockSchema = readJson(path.join(packageRoot, 'contracts', 'atelier-lock.v1.schema.json'))
42
43
  const REVIEW_MARKER_RE = /(Atelier-Boundary-Review|boundary-review)\s*:\s*(approved|reviewed)/i