rvlib-shipkit 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +12 -8
  4. package/src/assets/server/studio-server.ts +14 -2
  5. package/src/check.ts +5 -8
  6. package/src/checks/SK001-package-json-shape.check.ts +5 -4
  7. package/src/checks/SK003-claude-md-shape.check.ts +2 -2
  8. package/src/checks/SK030-cache-audit.check.ts +34 -0
  9. package/src/checks/SK031-trace-audit.check.ts +39 -0
  10. package/src/checks/SK032-ignored-inputs.check.ts +79 -0
  11. package/src/cli/cmd_Assets.ts +10 -6
  12. package/src/cli/cmd_CacheAudit.ts +43 -0
  13. package/src/cli/cmd_CrossRepoActivity.ts +2 -1
  14. package/src/cli/cmd_GlobalProcesses.ts +4 -3
  15. package/src/cli/cmd_Home.ts +3 -2
  16. package/src/cli/cmd_Keys.ts +12 -1
  17. package/src/cli/cmd_Overview.ts +1 -1
  18. package/src/cli/cmd_Serve.ts +2 -1
  19. package/src/cli/cmd_TraceAudit.ts +62 -0
  20. package/src/cli.ts +4 -0
  21. package/src/collections/collection-types.ts +3 -0
  22. package/src/collections/providers.ts +5 -3
  23. package/src/core/app-icon.ts +154 -0
  24. package/src/core/audit-report.ts +21 -0
  25. package/src/core/cache-audit.ts +72 -0
  26. package/src/core/canonical-scripts.ts +1 -1
  27. package/src/core/check-ctx.ts +14 -5
  28. package/src/core/check-runner.ts +6 -3
  29. package/src/core/floor-resolve.ts +5 -7
  30. package/src/core/git-root.ts +13 -0
  31. package/src/core/ignored-block.ts +94 -0
  32. package/src/core/ignored-inputs.ts +47 -0
  33. package/src/core/jobs.ts +9 -2
  34. package/src/core/one-lib.ts +59 -4
  35. package/src/core/read-set.ts +97 -9
  36. package/src/core/run-ci.ts +6 -1
  37. package/src/core/run-fixers.ts +8 -2
  38. package/src/core/trace-audit.ts +120 -0
  39. package/src/home/gui-app.ts +24 -27
  40. package/src/home/home-start.ts +11 -5
  41. package/src/home/open-app.ts +2 -2
  42. package/src/home/via-hub.ts +14 -0
  43. package/src/ledger/db.ts +2 -13
  44. package/src/ledger/gate-checks.ts +21 -3
  45. package/src/ledger/git-key.ts +148 -30
  46. package/src/ledger/git-scan.ts +5 -2
  47. package/src/ledger/ledger.ts +131 -16
  48. package/src/ledger/store.ts +14 -0
  49. package/src/ledger/toolchain-identity.ts +210 -25
  50. package/src/problems/extract.ts +3 -0
  51. package/src/repo-config.ts +18 -1
  52. package/src/serve/hub-app/admin-svc.ts +36 -3
  53. package/src/serve/hub-app/admin-view.tsx +33 -1
  54. package/src/serve/hub-app/col-card.tsx +1 -44
  55. package/src/serve/hub-app/col-tables-view.tsx +71 -5
  56. package/src/serve/hub-app/col-tables.ts +27 -0
  57. package/src/serve/hub-app/home-st.ts +1 -1
  58. package/src/serve/hub-app/home-strip.css +5 -9
  59. package/src/serve/hub-app/home-strip.tsx +4 -17
  60. package/src/serve/hub-app/hub-app.tsx +18 -4
  61. package/src/serve/hub-app/hub.css +24 -20
  62. package/src/serve/hub-app/keys.ts +10 -2
  63. package/src/serve/hub-app/processes-view.tsx +16 -9
  64. package/src/serve/hub-app/project-list.css +4 -1
  65. package/src/serve/hub-app/project-list.tsx +6 -3
  66. package/src/serve/hub-app/projects-svc.ts +10 -0
  67. package/src/serve/hub-app/repo-sections.ts +31 -17
  68. package/src/serve/hub-app/repo-st.ts +24 -5
  69. package/src/serve/hub-app/sidebar.tsx +5 -5
  70. package/src/serve/hub-app/tree-build.ts +2 -0
  71. package/src/serve/hub-app/tree-row.tsx +18 -5
  72. package/src/serve/hub-app/tree-svc.ts +27 -7
  73. package/src/serve/hub-runtime.ts +4 -2
  74. package/src/serve/project-gutter-route.ts +22 -1
  75. package/src/suite/judge-suites.ts +6 -0
  76. package/src/trace/interpose.c +655 -0
  77. package/src/trace/read-trace.ts +485 -0
  78. package/src/trace/tracer.ts +282 -0
@@ -0,0 +1,282 @@
1
+ // TRACING A TASK: the interposer built once per machine and version, the env a
2
+ // traced spawn carries, and the traceable copies of the binaries macOS would
3
+ // otherwise run blind.
4
+ //
5
+ // Why a copy works: dyld ignores DYLD_INSERT_LIBRARIES in a binary signed with
6
+ // the hardened runtime or library validation (Microsoft's native tsc, Xcode's
7
+ // git), and SIP drops it for everything under /usr and /bin. An ad hoc signed
8
+ // COPY of the same bytes is neither, so it runs traced. It sits beside the
9
+ // original, because a tool finds its own files next to itself (tsc's lib
10
+ // files), or for git in a prefix of links to the original's dirs. The system
11
+ // shells cannot be copied: they are arm64e, which macOS runs only when Apple
12
+ // signed them, so a script that needs /bin/sh stays untraced and says so.
13
+ //
14
+ // The tracer only AUDITS (core/trace-audit.ts): no verdict depends on a trace.
15
+ // It is on wherever it can run (Apple silicon macOS and a C compiler). A
16
+ // machine opts out with `sk trace-audit --disable`, which the daemon's health
17
+ // pass sees, and one run with SHIPKIT_TRACE=0.
18
+ import { createHash } from 'crypto'
19
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'fs'
20
+ import { homedir, tmpdir } from 'os'
21
+ import { basename, dirname, join } from 'path'
22
+ import { machineDelete, machineGet, machineSet } from '../core/machine-db.ts'
23
+
24
+ /** the env var that turns tracing on (1) or off (0) for one run, over the
25
+ * machine's setting */
26
+ export const TRACE_ENV = 'SHIPKIT_TRACE'
27
+
28
+ /** the machine setting `sk trace-audit --disable` writes (false) */
29
+ export const TRACE_SETTING = 'trace.enabled'
30
+
31
+ let settingMemo: { at: number; on: boolean } | null = null
32
+ /** whether this machine left tracing on, asked at most every few seconds:
33
+ * the composer asks it for every repo */
34
+ export function traceEnabledHere(now = Date.now()): boolean {
35
+ if (settingMemo != null && now - settingMemo.at < 5_000) return settingMemo.on
36
+ settingMemo = { at: now, on: machineGet<boolean>(TRACE_SETTING) !== false }
37
+ return settingMemo.on
38
+ }
39
+
40
+ /** turn tracing back on for this machine (the default), or off */
41
+ export function setTraceEnabled(on: boolean): void {
42
+ if (on) machineDelete(TRACE_SETTING)
43
+ else machineSet(TRACE_SETTING, false)
44
+ settingMemo = null
45
+ }
46
+
47
+ const SOURCE = join(import.meta.dir, 'interpose.c')
48
+
49
+ /** where the built interposer and the traceable copies live */
50
+ export const traceCache = (): string => process.env['SHIPKIT_TRACE_CACHE'] ?? join(homedir(), '.cache', 'shipkit', 'trace')
51
+
52
+ /** why this machine cannot trace, null when it can. Cheap: no build */
53
+ export function traceUnavailable(env: Record<string, string | undefined> = process.env): string | null {
54
+ const on = env[TRACE_ENV] === '1' || (env[TRACE_ENV] !== '0' && traceEnabledHere())
55
+ if (!on) return env[TRACE_ENV] === '0' ? 'tracing is off for this run (SHIPKIT_TRACE=0)' : 'tracing is off on this machine: sk trace-audit --enable turns it back on'
56
+ if (process.platform !== 'darwin' || process.arch !== 'arm64') return 'tracing runs on Apple silicon macOS only'
57
+ if (compiler() == null) return 'no C compiler to build the tracer (xcode-select --install)'
58
+ return null
59
+ }
60
+
61
+ const compiler = (): string | null => (existsSync('/usr/bin/clang') ? '/usr/bin/clang' : Bun.which('clang'))
62
+
63
+ let built: { path: string } | { reason: string } | null = null
64
+
65
+ /** the interposer for this source, built on first use. A build that fails is
66
+ * a reason, never a throw: the task then runs untraced and is not kept */
67
+ export function tracerLib(env: Record<string, string | undefined> = process.env): { path: string } | { reason: string } {
68
+ // the switch is asked every time: only the build is remembered
69
+ const off = traceUnavailable(env)
70
+ if (off != null) return { reason: off }
71
+ if (built != null) return built
72
+ let src: Buffer
73
+ try { src = readFileSync(SOURCE) } catch (e) { return (built = { reason: `the tracer source is missing: ${(e as Error).message}` }) }
74
+ const id = createHash('sha256').update(src).digest('hex').slice(0, 16)
75
+ const out = join(traceCache(), `interpose-${id}.dylib`)
76
+ if (existsSync(out)) return (built = { path: out })
77
+ mkdirSync(traceCache(), { recursive: true })
78
+ // built beside its final name and renamed in, so a parallel gate never
79
+ // loads a half-written library
80
+ const tmp = `${out}.${process.pid}.tmp`
81
+ const r = Bun.spawnSync([compiler()!, '-dynamiclib', '-O2', '-arch', 'arm64', '-arch', 'arm64e', '-Wno-deprecated-declarations', '-o', tmp, SOURCE], { stdout: 'pipe', stderr: 'pipe' })
82
+ if (r.exitCode !== 0) {
83
+ rmSync(tmp, { force: true })
84
+ return (built = { reason: `the tracer did not build: ${r.stderr.toString().trim().split('\n')[0] ?? `exit ${r.exitCode}`}` })
85
+ }
86
+ renameSync(tmp, out)
87
+ return (built = { path: out })
88
+ }
89
+
90
+ /** the remap file the interposer reads: original path, copy, and the size and
91
+ * mtime the copy was made from, so a replaced original is never swapped */
92
+ const remapFile = (): string => join(traceCache(), 'remap.tsv')
93
+
94
+ type Remap = { from: string; to: string; size: number; mtime: number; env: string }
95
+
96
+ function readRemaps(): Remap[] {
97
+ try {
98
+ return readFileSync(remapFile(), 'utf8').split('\n').filter((l) => l !== '' && !l.startsWith('#')).map((l) => {
99
+ const [from, to, size, mtime, env] = l.split('\t')
100
+ return { from: from!, to: to!, size: Number(size), mtime: Number(mtime), env: env ?? '' }
101
+ })
102
+ } catch {
103
+ return []
104
+ }
105
+ }
106
+
107
+ function writeRemaps(rows: Remap[]): void {
108
+ mkdirSync(traceCache(), { recursive: true })
109
+ const tmp = `${remapFile()}.${process.pid}.tmp`
110
+ writeFileSync(tmp, rows.map((r) => [r.from, r.to, r.size, r.mtime, r.env].join('\t')).join('\n') + '\n')
111
+ renameSync(tmp, remapFile())
112
+ }
113
+
114
+ const fresh = (r: Remap): boolean => {
115
+ try {
116
+ const s = statSync(r.from)
117
+ return s.size === r.size && Math.floor(s.mtimeMs / 1000) === r.mtime && existsSync(r.to)
118
+ } catch {
119
+ return false
120
+ }
121
+ }
122
+
123
+ function upsert(rows: Remap[], from: string, to: string, env = ''): Remap[] {
124
+ const s = statSync(from)
125
+ return [...rows.filter((r) => r.from !== from), { from, to, size: s.size, mtime: Math.floor(s.mtimeMs / 1000), env }]
126
+ }
127
+
128
+ /** an ad hoc signed copy of `bin` at `to`: the same bytes, no hardened runtime */
129
+ function signedCopy(bin: string, to: string): string | null {
130
+ mkdirSync(dirname(to), { recursive: true })
131
+ const tmp = `${to}.${process.pid}.tmp`
132
+ try {
133
+ copyFileSync(bin, tmp)
134
+ chmodSync(tmp, 0o755)
135
+ const r = Bun.spawnSync(['/usr/bin/codesign', '-f', '-s', '-', tmp], { stdout: 'pipe', stderr: 'pipe' })
136
+ if (r.exitCode !== 0) { rmSync(tmp, { force: true }); return `codesign refused it: ${r.stderr.toString().trim().split('\n').at(-1)}` }
137
+ renameSync(tmp, to)
138
+ return null
139
+ } catch (e) {
140
+ rmSync(tmp, { force: true })
141
+ return (e as Error).message
142
+ }
143
+ }
144
+
145
+ /** /usr/bin/git is a shim into Xcode's git, and both are blind. The copy
146
+ * lives in a prefix whose other dirs link to Xcode's, so it finds its
147
+ * exec-path, templates and system config exactly where the original does */
148
+ function gitRemap(rows: Remap[]): Remap[] {
149
+ if (!existsSync('/usr/bin/git')) return rows
150
+ const found = Bun.spawnSync(['/usr/bin/xcrun', '-f', 'git'], { stdout: 'pipe', stderr: 'pipe' })
151
+ const dev = found.exitCode === 0 ? found.stdout.toString().trim() : ''
152
+ if (dev === '' || !existsSync(dev)) return rows
153
+ const s = statSync(dev)
154
+ const prefix = join(traceCache(), `git-${createHash('sha256').update(`${dev}\0${s.size}\0${s.mtimeMs}`).digest('hex').slice(0, 12)}`)
155
+ const copy = join(prefix, 'usr', 'bin', 'git')
156
+ if (!existsSync(copy)) {
157
+ const devUsr = dirname(dirname(dev))
158
+ mkdirSync(join(prefix, 'usr'), { recursive: true })
159
+ for (const d of readdirSync(devUsr)) if (d !== 'bin' && !existsSync(join(prefix, 'usr', d))) symlinkSync(join(devUsr, d), join(prefix, 'usr', d))
160
+ if (signedCopy(dev, copy) != null) return rows
161
+ }
162
+ return upsert(upsert(rows, '/usr/bin/git', copy), dev, copy)
163
+ }
164
+
165
+ /** the remap file for this run, with every entry still describing its
166
+ * original. Rebuilt when one went stale (a `bun install` replaced a copy) */
167
+ export function remaps(): string {
168
+ let rows = readRemaps()
169
+ const git = rows.find((r) => r.from === '/usr/bin/git')
170
+ if (git == null || !fresh(git)) rows = gitRemap(rows)
171
+ const kept = rows.filter(fresh)
172
+ if (kept.length !== rows.length || !existsSync(remapFile())) writeRemaps(kept)
173
+ return remapFile()
174
+ }
175
+
176
+ /** SIP paths, where no copy runs traced */
177
+ const PROTECTED = /^\/(usr\/(bin|sbin|libexec|lib)|bin|sbin|System)\//
178
+
179
+ /** a binary that ran blind, made traceable for the next run when it can be:
180
+ * a hardened binary a repo installed. Returns what happened, in words, for
181
+ * the line that says the answer was not kept */
182
+ export function learnBlind(paths: string[]): string | null {
183
+ const said: string[] = []
184
+ let rows = readRemaps()
185
+ for (const p of paths) {
186
+ if (PROTECTED.test(p)) { said.push(`macOS lets no tool trace ${p}`); continue }
187
+ let head: Buffer
188
+ try { head = readFileSync(p).subarray(0, 256) } catch { continue }
189
+ if (head.subarray(0, 2).toString() === '#!') {
190
+ const interp = head.toString().slice(2).split('\n')[0]!.trim().split(/\s+/)[0] ?? ''
191
+ said.push(`${basename(p)} runs through ${interp}${PROTECTED.test(interp) ? ', which macOS lets no tool trace' : ''}`)
192
+ continue
193
+ }
194
+ const magic = head.readUInt32BE(0)
195
+ if (![0xcffaedfe, 0xcafebabe, 0xfeedfacf].includes(magic)) continue
196
+ // a copy goes beside the original, so only into an install the repo
197
+ // owns, and only for a binary whose signature is what blinds it
198
+ const sign = Bun.spawnSync(['/usr/bin/codesign', '-dv', p], { stdout: 'pipe', stderr: 'pipe' }).stderr.toString()
199
+ if (!p.includes('/node_modules/') || !/flags=0x[0-9a-f]+\((?:[^)]*)(runtime|library-validation)/.test(sign)) { said.push(`${p} runs untraced and cannot be made traceable`); continue }
200
+ const copy = join(dirname(p), `.${basename(p)}.shipkit-traced`)
201
+ const failed = signedCopy(p, copy)
202
+ if (failed != null) { said.push(`${p} cannot be copied: ${failed}`); continue }
203
+ rows = upsert(rows, p, copy)
204
+ said.push(`a traceable copy of ${basename(p)} is ready for the next run`)
205
+ }
206
+ writeRemaps(rows)
207
+ return said.length === 0 ? null : said.join('; ')
208
+ }
209
+
210
+ /** a plain script: commands, arguments, quotes, `&&`, `||`, `;` and `|`.
211
+ * Bun's shell runs exactly that the way sh does, in process, so a traced
212
+ * task keeps its trace; anything else is left to the system shell */
213
+ export function plainScript(body: string): boolean {
214
+ if (!/^[\w\s@%+=:,./'"&|;-]*$/.test(body)) return false
215
+ // an unclosed quote runs the rest of the line into one word
216
+ if ((body.match(/'/g) ?? []).length % 2 === 1 || (body.match(/"/g) ?? []).length % 2 === 1) return false
217
+ const words = body.split(/[\s&|;]+/).filter((w) => w !== '')
218
+ const shellOnly = new Set(['export', 'set', 'source', '.', 'eval', 'exec', 'trap', 'if', 'then', 'else', 'fi', 'for', 'do', 'done', 'while', 'case', 'esac', 'function', 'unset', 'shift', 'read'])
219
+ return !words.some((w) => shellOnly.has(w))
220
+ }
221
+
222
+ /** what a traced task actually runs, where its trace goes, and the env that
223
+ * traces it, or why it cannot be traced. `bun run <script>` runs a plain
224
+ * script through bun's shell, the only one macOS lets a trace into */
225
+ export function tracedCommand(cmd: string[], cwd: string, env: Record<string, string | undefined> = process.env): { cmd: string[]; dir: string; env: Record<string, string>; note: string | null } | { reason: string } {
226
+ const lib = tracerLib(env)
227
+ if ('reason' in lib) return lib
228
+ // the copies first: the root command may be one of the binaries they stand for
229
+ const remap = remaps()
230
+ let run = rootCommand(cmd)
231
+ let note: string | null = null
232
+ if (cmd.length === 3 && basename(cmd[0]!) === 'bun' && cmd[1] === 'run') {
233
+ let body: string | null = null
234
+ try { body = (JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }).scripts?.[cmd[2]!] ?? null } catch { body = null }
235
+ if (body != null && plainScript(body)) run = [cmd[0]!, 'run', '--shell=bun', cmd[2]!]
236
+ else if (body != null) note = `its script \`${cmd[2]}\` uses shell syntax only /bin/sh runs`
237
+ }
238
+ return { cmd: run, ...traceSpawn(lib.path, remap), note }
239
+ }
240
+
241
+ /** the task's own command, spawned the way the interposer spawns a child:
242
+ * the gate itself runs untraced, so nobody else rewrites a
243
+ * `#!/usr/bin/env node` launcher (a floor slot's `node_modules/.bin/tsc`)
244
+ * or swaps a hardened binary for its traceable copy */
245
+ export function rootCommand(cmd: string[]): string[] {
246
+ if (cmd.length === 0) return cmd
247
+ const first = cmd[0]!
248
+ const path = first.includes('/') ? first : Bun.which(first) ?? first
249
+ let head = ''
250
+ try { head = readFileSync(path).subarray(0, 512).toString() } catch { return cmd }
251
+ if (head.startsWith('#!')) {
252
+ const [interp, prog, ...rest] = head.slice(2).split('\n')[0]!.trim().split(/\s+/)
253
+ if (interp === '/usr/bin/env' && prog != null && !prog.startsWith('-') && rest.length === 0) {
254
+ const resolved = Bun.which(prog)
255
+ if (resolved != null) return [resolved, path, ...cmd.slice(1)]
256
+ }
257
+ return cmd
258
+ }
259
+ const copy = readRemaps().find((r) => r.from === path && fresh(r))
260
+ return copy == null ? cmd : [copy.to, ...cmd.slice(1)]
261
+ }
262
+
263
+ /** a fresh dir for one traced spawn, and the env that traces it. A gate
264
+ * running inside a traced task keeps the outer dir, so both see the run */
265
+ export function traceSpawn(lib: string, remap: string = remaps()): { dir: string; env: Record<string, string> } {
266
+ const dir = mkdtempSync(join(tmpdir(), 'shipkit-trace-'))
267
+ const outer = process.env['SHIPKIT_TRACE_DIR']
268
+ const inserted = process.env['DYLD_INSERT_LIBRARIES']
269
+ return {
270
+ dir,
271
+ env: {
272
+ SHIPKIT_TRACE_DIR: outer == null || outer === '' ? dir : `${outer}:${dir}`,
273
+ DYLD_INSERT_LIBRARIES: inserted == null || inserted === '' ? lib : inserted.split(':').includes(lib) ? inserted : `${lib}:${inserted}`,
274
+ SHIPKIT_TRACE_REMAP: remap,
275
+ },
276
+ }
277
+ }
278
+
279
+ /** a trace dir goes once it is read */
280
+ export function dropTrace(dir: string): void {
281
+ try { if (lstatSync(dir).isDirectory()) rmSync(dir, { recursive: true, force: true }) } catch { /* gone either way */ }
282
+ }