@starci/hfs 4.0.9 → 4.0.10

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/LICENSE +21 -0
  2. package/lint/run.mjs +80 -7
  3. package/package.json +3 -2
  4. package/runtime/engine/admission.mjs +308 -0
  5. package/runtime/engine/canonical-json.mjs +10 -0
  6. package/runtime/engine/config.mjs +36 -44
  7. package/runtime/engine/db/blob.mjs +315 -0
  8. package/runtime/engine/db/ledger-paths.mjs +83 -0
  9. package/runtime/engine/db/ledger.mjs +1118 -0
  10. package/runtime/engine/db/machine-connection.mjs +135 -0
  11. package/runtime/engine/db/machine-schema.mjs +84 -0
  12. package/runtime/engine/db/machine.mjs +1367 -0
  13. package/runtime/engine/db/migrations/machine/0001-init.sql +924 -0
  14. package/runtime/engine/db/migrations/runtime/0001-init.sql +1108 -0
  15. package/runtime/engine/db/provider-reservations.mjs +101 -0
  16. package/runtime/engine/digest.mjs +16 -0
  17. package/runtime/engine/refuse.mjs +11 -0
  18. package/runtime/engine/secrets.mjs +130 -0
  19. package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
  20. package/runtime/knowledge/hfs/rules.yaml +8 -8
  21. package/runtime/modules/kernel/failure-codes.yaml +40 -0
  22. package/runtime/modules/models/registry.yaml +1 -39
  23. package/runtime/modules/models/runtimes.yaml +0 -6
  24. package/runtime/modules/ops/_labels.yaml +53 -0
  25. package/runtime/scripts/api/fs/lib.mjs +6 -0
  26. package/runtime/scripts/api/git/lib.mjs +2 -0
  27. package/runtime/scripts/api/node/lib.mjs +14 -0
  28. package/runtime/scripts/api/node/spawn-node.mjs +6 -0
  29. package/runtime/scripts/api/process/lib.mjs +111 -0
  30. package/runtime/scripts/api/process/owned-process.mjs +78 -0
  31. package/runtime/scripts/api/process/resolve-real-tool.mjs +46 -0
  32. package/runtime/scripts/api/process/run-program.mjs +7 -0
  33. package/runtime/scripts/api/process/stop-owned-process.mjs +9 -0
  34. package/runtime/scripts/api/sops/decrypt.mjs +9 -16
  35. package/runtime/scripts/api/sops/lib.mjs +230 -10
  36. package/runtime/scripts/api/sops/seal.mjs +8 -4
  37. package/runtime/scripts/connectors/lib.mjs +488 -0
  38. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +1 -1
  39. package/runtime/scripts/hfs/secret.mjs +45 -11
  40. package/runtime/scripts/lib/clip.mjs +19 -0
  41. package/runtime/scripts/lib/display-names.mjs +259 -0
  42. package/runtime/scripts/lib/example-refs.mjs +158 -0
  43. package/runtime/scripts/lib/fs-kind.mjs +14 -4
  44. package/runtime/scripts/lib/json-schema.mjs +52 -0
  45. package/runtime/scripts/lib/mutation-fence.mjs +15 -0
  46. package/runtime/scripts/lib/path-key.mjs +4 -4
  47. package/runtime/scripts/lib/process-identity.mjs +6 -0
  48. package/runtime/scripts/lib/read-yaml.mjs +18 -0
  49. package/runtime/scripts/lib/redact.mjs +161 -0
  50. package/runtime/scripts/lib/sleep.mjs +7 -0
  51. package/runtime/scripts/lib/sops-envelope.mjs +54 -0
  52. package/runtime/scripts/lib/source-phrases.mjs +34 -0
  53. package/runtime/scripts/lib/sqlite.mjs +21 -0
@@ -6,6 +6,8 @@ import {parseYaml} from './yaml.mjs';
6
6
  import {isPlainObject as plain} from './plain-object.mjs';
7
7
  import {invalid,validateRoots} from './invalid-config.mjs';
8
8
  import {validateOrca} from './orca-config.mjs';
9
+ import {ENV_NAME,secretEnv,connectorSecret} from './secrets.mjs';
10
+ export {readDotenv,connectorSecret} from './secrets.mjs';
9
11
 
10
12
  export const configRoot=skillRoot;
11
13
  export const NON_OPERATION_ROLES={planner:'plan',kernelManager:'decide',validator:'verify'};
@@ -40,6 +42,29 @@ export function runtimeProfile(){
40
42
  * here; a literal copy of any of them in a source file would be a second authority.
41
43
  */
42
44
  export function allocationSettings(){return runtimeProfile()?.allocation??{};}
45
+ // These are current owner declarations, never a historical runtime owner's consent.
46
+ function validateOwnerProfile(value,name,pathsKey){
47
+ if(value===null)return;
48
+ const bad=invalid(name);
49
+ if(!plain(value))bad(' must be an owner approval mapping or null.');
50
+ const keys=['approvedBy','approvalRef',pathsKey,...(name==='launchTrust'?['profile']:[])];
51
+ for(const key of Object.keys(value))if(!keys.includes(key))bad(` has unknown key ${key}.`);
52
+ if(value.approvedBy!=='owner')bad('.approvedBy must be owner; adoption must come from the current owner.');
53
+ if(typeof value.approvalRef!=='string'||!value.approvalRef.trim())bad('.approvalRef must identify the current owner approval.');
54
+ if(name==='launchTrust'&&!['automatic','declined'].includes(value.profile))bad('.profile must be automatic or declined.');
55
+ const paths=value[pathsKey];
56
+ if(!Array.isArray(paths)||!paths.length||paths.some(p=>typeof p!=='string'||!(path.isAbsolute(p)||path.win32.isAbsolute(p))||/[\r\n\0]/.test(p)))bad(`.${pathsKey} must list exact absolute repository roots.`);
57
+ }
58
+ export function launchTrustSettings(config=loadConfig()){
59
+ const value=config?.launchTrust??null;validateOwnerProfile(value,'launchTrust','roots');return value;
60
+ }
61
+ export function workflowPurgeSettings(config=loadConfig()){
62
+ const retention=config?.retention??null;
63
+ if(retention===null)return null;
64
+ const bad=invalid('retention');
65
+ if(!plain(retention)||Object.keys(retention).some(k=>k!=='workflowPurge'))bad(' must be {workflowPurge?} or null.');
66
+ const value=retention.workflowPurge??null;validateOwnerProfile(value,'retention.workflowPurge','repos');return value;
67
+ }
43
68
  /** One positive millisecond value out of `allocation`, by dotted key. Refuses when the contract omits it. */
44
69
  export function allocationMs(dotted){
45
70
  const raw=dotted.split('.').reduce((node,key)=>(node==null?node:node[key]),allocationSettings());
@@ -75,13 +100,11 @@ const CLOUDFLARE_MODES=['off','quick','named'];
75
100
  /** The serve-ask port band, both ends included (scripts/kernel/ask-server.mjs scans [first..last]); the gateway stays outside it. */
76
101
  export const ASK_PORT_BAND=[6969,7069];
77
102
  export const CONNECTOR_DEFAULTS=Object.freeze({
78
- secretsFile:null,
79
103
  repos:[],
80
104
  gateway:{port:7070},
81
105
  cloudflare:{mode:'off',tunnel:null,credentialsFile:null,tokenEnv:'CLOUDFLARE_TUNNEL_TOKEN',hostname:null,access:false},
82
106
  telegram:{enabled:false,botTokenEnv:'TELEGRAM_BOT_TOKEN',chatId:null,exposeCredentialAsks:false},
83
107
  });
84
- const ENV_NAME=/^[A-Z_][A-Z0-9_]{0,63}$/;
85
108
  const HOSTNAME=/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
86
109
  const TUNNEL_REF=/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
87
110
  const CHAT_ID=/^(?:-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{4,31})$/;
@@ -89,7 +112,7 @@ const SECRET_KEYS=['token','tunnelToken','botToken','secret','apiToken','passwor
89
112
  function validateConnectors(connectors){
90
113
  if(connectors===null)return;
91
114
  const bad=invalid('connectors');
92
- if(!plain(connectors))bad(' must be {secretsFile?, repos?, gateway?, cloudflare?, telegram?} or null.');
115
+ if(!plain(connectors))bad(' must be {repos?, gateway?, cloudflare?, telegram?} or null.');
93
116
  const closed=(node,where,keys)=>{
94
117
  if(!plain(node))bad(`.${where} must be a mapping.`);
95
118
  for(const key of Object.keys(node)){
@@ -97,8 +120,7 @@ function validateConnectors(connectors){
97
120
  if(!keys.includes(key))bad(`.${where} has unknown key ${key} (allowed: ${keys.join(', ')}).`);
98
121
  }
99
122
  };
100
- closed(connectors,'',['secretsFile','repos','gateway','cloudflare','telegram']);
101
- if(connectors.secretsFile!==undefined&&connectors.secretsFile!==null&&(typeof connectors.secretsFile!=='string'||!connectors.secretsFile.trim()))bad('.secretsFile must be a dotenv file path (relative to the skill root) or null.');
123
+ closed(connectors,'',['repos','gateway','cloudflare','telegram']);
102
124
  if(connectors.repos!==undefined&&(!Array.isArray(connectors.repos)||connectors.repos.some(repo=>typeof repo!=='string'||!repo.trim())))bad('.repos must be a list of repository paths.');
103
125
  if(connectors.gateway!==undefined){
104
126
  closed(connectors.gateway,'gateway',['port']);
@@ -132,46 +154,14 @@ function validateConnectors(connectors){
132
154
  if(tg.enabled===true&&(tg.chatId===undefined||tg.chatId===null))bad('.telegram.enabled needs telegram.chatId.');
133
155
  }
134
156
  }
135
- /**
136
- * The secret an env var NAME resolves to: `env[name]`, else the contents of the file `env[name + '_FILE']`
137
- * points at (the custody pointer convention). Returns null when neither is set. Callers pass the value to
138
- * the one API that needs it and never print it.
139
- */
140
- /** Parse a dotenv file (KEY=VALUE lines, # comments, optional export/quotes). An absent file is {}. */
141
- export function readDotenv(file){
142
- let text='';try{text=fs.readFileSync(file,'utf8');}catch(error){if(error?.code==='ENOENT')return {};throw error;}
143
- const out={};
144
- for(const line of text.split(/\r?\n/)){
145
- const m=line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
146
- if(!m)continue;
147
- let value=m[2];
148
- if(value.length>=2&&(value[0]==='"'||value[0]==="'")&&value.at(-1)===value[0])value=value.slice(1,-1);
149
- out[m[1]]=value;
150
- }
151
- return out;
152
- }
153
- /** The dotenv file connectors.secretsFile names, resolved against the skill root; null when unset. */
154
- const connectorSecretsFile=(config,root=configRoot)=>{const file=config?.connectors?.secretsFile;return typeof file==='string'&&file.trim()?path.resolve(root,file):null;};
155
- /**
156
- * The environment the connectors resolve secrets from: connectors.secretsFile's values under the process
157
- * environment (a real env var wins). It holds secrets — hand it to connectorSecret, never print it.
158
- */
159
- export function connectorEnv(config,env=process.env,root=configRoot){const file=connectorSecretsFile(config,root);return file?{...readDotenv(file),...env}:env;}
160
- export function connectorSecret(name,env=process.env){
161
- if(typeof name!=='string'||!ENV_NAME.test(name))return null;
162
- const direct=typeof env[name]==='string'?env[name].trim():'';
163
- if(direct)return direct;
164
- const pointer=typeof env[`${name}_FILE`]==='string'?env[`${name}_FILE`].trim():'';
165
- if(!pointer)return null;
166
- try{const value=fs.readFileSync(pointer,'utf8').trim();return value||null;}catch{return null;}
167
- }
157
+ /** Load connector credentials from the explicitly verified runtime main; never print the returned environment. */
158
+ export function connectorEnv(config,env=process.env,verifiedRuntimeRoot){return secretEnv(verifiedRuntimeRoot,env);}
168
159
  /**
169
160
  * The normalized `connectors` block: defaults filled in, the secrets' PRESENCE (booleans, never values),
170
161
  * whether each connector is ready to run, and the owner-facing warnings the security posture earns.
171
162
  */
172
163
  export function connectorsConfig(config=loadConfig(),env=process.env,root=configRoot){
173
164
  validateConfig(config);
174
- env=connectorEnv(config,env,root);
175
165
  const raw=plain(config?.connectors)?config.connectors:{},d=CONNECTOR_DEFAULTS;
176
166
  const cloudflare={...d.cloudflare,...(raw.cloudflare??{})},telegram={...d.telegram,...(raw.telegram??{})};
177
167
  if(cloudflare.credentialsFile)cloudflare.credentialsFile=path.resolve(root,cloudflare.credentialsFile.replace(/^~(?=[\\/])/,env.USERPROFILE??env.HOME??'~'));
@@ -189,7 +179,7 @@ export function connectorsConfig(config=loadConfig(),env=process.env,root=config
189
179
  if(cloudflare.auth==='token'&&!cloudflare.tokenPresent)warnings.push(`cloudflare.mode named: the tunnel token is not set — export ${cloudflare.tokenEnv} (or ${cloudflare.tokenEnv}_FILE).`);
190
180
  if(telegram.enabled&&!telegram.botTokenPresent)warnings.push(`telegram.enabled: the bot token is not set — export ${telegram.botTokenEnv} (or ${telegram.botTokenEnv}_FILE).`);
191
181
  if(telegram.exposeCredentialAsks)warnings.push('telegram.exposeCredentialAsks is true: credential asks are posted as public links; Telegram cloud chats are not end-to-end encrypted.');
192
- return {secretsFile:connectorSecretsFile(config,root),repos:[...(raw.repos??d.repos)],gateway:{...d.gateway,...(raw.gateway??{})},cloudflare,telegram,warnings};
182
+ return {repos:[...(raw.repos??d.repos)],gateway:{...d.gateway,...(raw.gateway??{})},cloudflare,telegram,warnings};
193
183
  }
194
184
  /**
195
185
  * config.yaml `asks` — whether an owner ask that carries a recommended option is answered with it
@@ -305,7 +295,9 @@ function validateAgentSeat(seat,name,profile){
305
295
  if(seat.effort!==undefined&&seat.effort!==null&&!EFFORT_LEVELS.includes(seat.effort))throw Error(`Invalid config.yaml: ${name}.effort must use the effort vocabulary.`);
306
296
  }
307
297
  export function validateConfig(config){
308
- const allowed=['language','model','effort','models','debug','allocation','kernel','budgets','supervisor','parallel','delegation','connectors','asks','uat','specs','reconciler','coreDebug','orca','roots'],models=config?.models,profile=runtimeProfile(),runtimes=profile?.runtimes??{};
298
+ const allowed=['language','model','effort','models','debug','allocation','kernel','budgets','supervisor','parallel','delegation','connectors','asks','uat','specs','reconciler','coreDebug','orca','roots','launchTrust','retention'],models=config?.models,profile=runtimeProfile(),runtimes=profile?.runtimes??{};
299
+ if(config?.launchTrust!==undefined)launchTrustSettings(config);
300
+ if(config?.retention!==undefined)workflowPurgeSettings(config);
309
301
  if(config?.connectors!==undefined)validateConnectors(config.connectors);
310
302
  if(config?.asks!==undefined)validateAsks(config.asks);
311
303
  if(config?.uat!==undefined)validateUat(config.uat);
@@ -372,8 +364,8 @@ export function validateConfig(config){
372
364
  }
373
365
  if(config?.budgets!==undefined){
374
366
  const budgets=config.budgets;
375
- if(!plain(budgets)||Object.keys(budgets).some(key=>!['maxOps','perOpMs','dailyTokens'].includes(key))||Object.values(budgets).some(value=>value!==null&&!(Number.isInteger(value)&&value>0)))
376
- throw Error('Invalid config.yaml: budgets must be {maxOps?, perOpMs?, dailyTokens?} with positive-integer-or-null values.');
367
+ if(!plain(budgets)||Object.keys(budgets).some(key=>!['maxOps'].includes(key))||Object.values(budgets).some(value=>value!==null&&!(Number.isInteger(value)&&value>0)))
368
+ throw Error('Invalid config.yaml: budgets must be {maxOps?} with positive-integer-or-null values.');
377
369
  }
378
370
  if(!plain(config)||Object.keys(config).some(key=>!allowed.includes(key))||typeof config.language!=='string'||!/^[a-z]{2,3}(?:-[A-Za-z0-9]+)*$/.test(config.language)||!(config.model===null||typeof config.model==='string'&&config.model.trim())||!EFFORT_LEVELS.includes(config.effort)||!plain(models)||Object.keys(models).some(key=>!['pools','nonOperation','selection'].includes(key))||models.selection!=='quota-aware'||!plain(models.pools)||!plain(models.nonOperation)||Object.keys(models.pools).length!==Object.keys(DEFAULT_MODEL_POOLS).length||Object.keys(models.pools).some(key=>!Object.hasOwn(DEFAULT_MODEL_POOLS,key))||Object.keys(models.nonOperation).length!==Object.keys(NON_OPERATION_ROLES).length||Object.keys(models.nonOperation).some(key=>!Object.hasOwn(NON_OPERATION_ROLES,key)))throw Error('Invalid config.yaml: expected language, model, effort and the closed quota-aware model pools/non-operation role map.');
379
371
  for(const [pool,members] of Object.entries(models.pools))if(!Array.isArray(members)||members.length!==2||new Set(members).size!==2||members.some(id=>typeof id!=='string'||!plain(runtimes[id]))||!(members.length===DEFAULT_MODEL_POOLS[pool].length&&members.every(id=>DEFAULT_MODEL_POOLS[pool].includes(id))))throw Error(`Invalid config.yaml: models.pools.${pool} must contain its canonical pair of two unique known runtime ids.`);
@@ -444,7 +436,7 @@ export function inspectOwnerConfig(root=configRoot){
444
436
  * or `starci kernel run-deferred-tests`); e2e.verify then runs the FULL e2e suite.
445
437
  * An absent, null or unreadable owner file reads as the defaults: harness off, unit on, e2e off.
446
438
  */
447
- export const SPEC_FAMILIES=Object.freeze(['harness','unit','e2e']);
439
+ const SPEC_FAMILIES=Object.freeze(['harness','unit','e2e']);
448
440
  export const SPEC_DEFAULTS=Object.freeze({harness:false,unit:true,e2e:false});
449
441
  export function specsSettings(config){const specs=plain(config?.specs)?config.specs:{};return Object.fromEntries(SPEC_FAMILIES.map(key=>[key,typeof specs[key]==='boolean'?specs[key]:SPEC_DEFAULTS[key]]));}
450
442
  /** specs.harness of the owner file under `root` (tolerant read: inspectOwnerConfig): true only when the owner opted in to `--specs all`. */
@@ -0,0 +1,315 @@
1
+ // blob.mjs — the content-addressed blob store (operational evidence and agent output) beside the two SQLite stores:
2
+ // putBlob/getBlob/statBlob/putJson write and read it; blob bytes are immutable and never removed here.
3
+ //
4
+ // Reading agent output back out of the blob store (alpha.3, ARCHITECTURE-DB §5.3). A Work record and the
5
+ // draw subsystem cite agent output by {artifact?, sha256}; this is the one place that turns such a citation back into
6
+ // bytes, a readable file with an extension, or a directory.
7
+ // resolveBlob(ref, {db}) ref = {artifact?, sha256?} | 'blob:<sha>' | '<sha>' → {sha256, file} | null (an artifact id
8
+ // alone resolves through the ledger's job_artifacts)
9
+ // readBlobRef(ref) the verified bytes
10
+ // blobAsFile(ref, {ext}) a read-only copy with an extension beside the store (<artifact root>-views; tools that key on the
11
+ // extension: an image decoder, a browser), cached by sha
12
+ // putBundle(dir) every file under `dir` put as a blob + a starci/blob-bundle@1 manifest {files: {rel: sha}}
13
+ // put as a blob; returns the manifest sha (a draw loop: loop.json and every round)
14
+ // bundleDir(sha) the bundle materialized as a directory under <artifact root>-views (cached by sha), or null
15
+ // Each materialized view is verified against its cited digest before reuse.
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import crypto from 'node:crypto';
20
+
21
+ export const ARTIFACT_ROOT_ENV = 'STARCI_ARTIFACT_ROOT';
22
+ export const artifactRoot = (env = process.env) => path.resolve(env[ARTIFACT_ROOT_ENV] || path.join(os.homedir(), '.starci', 'artifacts'));
23
+ const SHA = /^[a-f0-9]{64}$/;
24
+ const assertSha = sha => {
25
+ if (typeof sha !== 'string' || !SHA.test(sha)) throw new TypeError('blob sha must be a lowercase sha256 hex digest');
26
+ return sha;
27
+ };
28
+ // An explicit root scopes READs only; writers continue to use the default external root.
29
+ const location = (sha, root = null, suffix = '') => {
30
+ if (root !== null && (typeof root !== 'string' || !root || !path.isAbsolute(root)))
31
+ throw new TypeError('blob read root must be an absolute path');
32
+ const file = path.join(root === null ? artifactRoot() : path.resolve(root), assertSha(sha).slice(0, 2), `${sha}${suffix}`);
33
+ if (root !== null) {
34
+ regularParents(file, { strict: true });
35
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
36
+ if (stat && (!stat.isFile() || stat.isSymbolicLink()
37
+ || fs.realpathSync.native(file) !== path.join(fs.realpathSync.native(path.dirname(file)), path.basename(file))
38
+ || !fs.readdirSync(path.dirname(file)).includes(path.basename(file))))
39
+ throw new Error(`blob read path is not a contained regular file: ${file}`);
40
+ }
41
+ return file;
42
+ };
43
+ const metadataPath = (sha, root = null) => location(sha, root, '.json');
44
+ const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
45
+
46
+ // Reject roots inside a checkout, including worktrees whose .git is a file.
47
+ function ensureExternalRoot(root) {
48
+ let cursor = root;
49
+ while (true) {
50
+ if (fs.existsSync(path.join(cursor, '.git'))) throw new Error(`artifact root is inside a git checkout: ${root}`);
51
+ const parent = path.dirname(cursor);
52
+ if (parent === cursor) return;
53
+ cursor = parent;
54
+ }
55
+ }
56
+
57
+ function sourceBytes(bufferOrPath) {
58
+ if (Buffer.isBuffer(bufferOrPath)) return bufferOrPath;
59
+ if (bufferOrPath instanceof Uint8Array) return Buffer.from(bufferOrPath);
60
+ if (typeof bufferOrPath === 'string') return fs.readFileSync(bufferOrPath);
61
+ throw new TypeError('putBlob expects a Buffer, Uint8Array, or file path');
62
+ }
63
+
64
+ function publishOnce(destination, bytes) {
65
+ const dir = path.dirname(destination);
66
+ fs.mkdirSync(dir, { recursive: true });
67
+ const temp = path.join(dir, `.${path.basename(destination)}.${process.pid}.${crypto.randomUUID()}.tmp`);
68
+ let owned=false;
69
+ try {
70
+ const fd=fs.openSync(temp,'wx');
71
+ owned=true;
72
+ try{fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);}finally{fs.closeSync(fd);}
73
+ try { fs.linkSync(temp, destination); }
74
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
75
+ } finally { if(owned)try { fs.unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
76
+ }
77
+
78
+ /** Store original bytes under their sha256. A concurrent or repeated put never replaces published bytes. */
79
+ export function putBlob(bufferOrPath, { mediaType = 'application/octet-stream' } = {}) {
80
+ if (typeof mediaType !== 'string' || !mediaType.trim()) throw new TypeError('mediaType must be a nonempty string');
81
+ const root = artifactRoot();
82
+ ensureExternalRoot(root);
83
+ const bytes = sourceBytes(bufferOrPath);
84
+ const sha = digest(bytes);
85
+ const destination = location(sha);
86
+ const metadata = { size: bytes.length, mediaType, createdAt: new Date().toISOString() };
87
+ // Metadata is published first so a visible blob always has a sidecar. A crash can leave an orphan sidecar;
88
+ // the next put completes it. The first published media type and creation time win.
89
+ publishOnce(metadataPath(sha), Buffer.from(JSON.stringify(metadata)));
90
+ publishOnce(destination, bytes);
91
+ const stored = fs.statSync(destination);
92
+ if (stored.size !== bytes.length) throw new Error(`blob size mismatch: ${sha}`);
93
+ // Reuse must verify bytes, including a corrupt same-size destination.
94
+ getBlob(sha);
95
+ const info = statBlob(sha);
96
+ // Flush reused files too. Directory/link persistence remains platform-specific;
97
+ // this local profile does not promise power-loss recovery of acknowledged refs.
98
+ for(const file of [destination,metadataPath(sha)]){const fd=fs.openSync(file,'r+');try{fs.fsyncSync(fd);}finally{fs.closeSync(fd);}}
99
+ return { sha, size: info.size, mediaType: info.mediaType };
100
+ }
101
+
102
+ export function blobPath(sha, { root = null } = {}) {
103
+ const file = location(sha, root);
104
+ return fs.existsSync(file) && fs.statSync(file).isFile() ? file : null;
105
+ }
106
+
107
+ export function getBlob(sha, { root = null, verifyBundle = false } = {}) {
108
+ const file = blobPath(sha, { root });
109
+ if (!file) throw Object.assign(new Error(`blob not found: ${sha}`), { code: 'ENOENT' });
110
+ const bytes = fs.readFileSync(file);
111
+ if (digest(bytes) !== sha) throw new Error(`blob hash mismatch: ${sha}`);
112
+ if (root !== null) statBlob(sha, { root });
113
+ if (verifyBundle) {
114
+ let manifest;
115
+ try { manifest = JSON.parse(bytes.toString('utf8')); } catch { /* ordinary blobs need not be JSON */ }
116
+ if (typeof manifest?.schema === 'string' && manifest.schema.startsWith('starci/blob-bundle@')
117
+ && !bundleDir(sha, { root }))
118
+ throw Object.assign(new Error(`blob bundle cannot be verified: ${sha}`), { code: 'EINVALBUNDLE' });
119
+ }
120
+ return bytes;
121
+ }
122
+
123
+ export function statBlob(sha, { root = null } = {}) {
124
+ const file = blobPath(sha, { root });
125
+ if (!file) return null;
126
+ const { size } = fs.statSync(file);
127
+ const metadata = JSON.parse(fs.readFileSync(metadataPath(sha, root), 'utf8'));
128
+ if (metadata.size !== size) throw new Error(`blob sidecar size mismatch: ${sha}`);
129
+ if(typeof metadata.mediaType!=='string'||!metadata.mediaType.trim()||!Number.isFinite(Date.parse(metadata.createdAt)))throw Error(`blob sidecar metadata invalid: ${sha}`);
130
+ return { sha, size, mediaType: metadata.mediaType, createdAt: metadata.createdAt };
131
+ }
132
+
133
+ function canonical(value) {
134
+ if (value && typeof value === 'object') {
135
+ if (typeof value.toJSON === 'function') return canonical(value.toJSON());
136
+ if (Array.isArray(value)) return value.map(item => item === undefined ? null : canonical(item));
137
+ return Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => [key, canonical(value[key])]));
138
+ }
139
+ return value;
140
+ }
141
+
142
+ /* ------------------------------------------------------------ citations, views and bundles */
143
+
144
+ const BUNDLE_SCHEMA = 'starci/blob-bundle@1';
145
+ // Read-only views (files with an extension, materialized bundles) live beside the store, e.g. ~/.starci/artifacts-views.
146
+ const viewRoot = (root = null) => `${root === null ? artifactRoot() : path.resolve(root)}-views`;
147
+ const slash = (p) => String(p).replace(/\\/g, '/');
148
+
149
+ /** The sha256 a citation names: {sha256}, 'blob:<sha>' or a bare sha; an {artifact} alone needs `db`. */
150
+ function shaOfRef(ref, { db = null } = {}) {
151
+ if (typeof ref === 'string') { const s = ref.trim().replace(/^blob:/, ''); return SHA.test(s) ? s : null; }
152
+ if (!ref || typeof ref !== 'object') return null;
153
+ if (typeof ref.sha256 === 'string' && SHA.test(ref.sha256)) return ref.sha256;
154
+ if (ref.artifact != null && db) return db.prepare('SELECT sha256 FROM job_artifacts WHERE artifact_id=?').get(Number(ref.artifact))?.sha256 ?? null;
155
+ return null;
156
+ }
157
+
158
+ /** {sha256, file} of a citation whose bytes are in the local store, else null. */
159
+ export function resolveBlob(ref, { db = null, root = null } = {}) {
160
+ const sha = shaOfRef(ref, { db });
161
+ const file = sha ? blobPath(sha, { root }) : null;
162
+ return file ? { sha256: sha, file } : null;
163
+ }
164
+
165
+ /** A read-only copy of the blob with `ext` (e.g. '.png'), cached beside the store; null when missing. */
166
+ export function blobAsFile(ref, { ext = '', db = null, root = null } = {}) {
167
+ const hit = resolveBlob(ref, { db, root });
168
+ if (!hit) return null;
169
+ if (typeof ext !== 'string' || (ext !== '' && !/^\.[a-z0-9]{1,16}$/i.test(ext))) throw new TypeError('blob view extension must be a simple suffix');
170
+ const bytes = getBlob(hit.sha256, { root });
171
+ const file = path.join(viewRoot(root), 'files', `${hit.sha256}${ext}`);
172
+ regularParents(file, { strict: root !== null });
173
+ publishOnce(file, bytes);
174
+ if (!fs.lstatSync(file).isFile() || fs.lstatSync(file).isSymbolicLink() || digest(fs.readFileSync(file)) !== hit.sha256)
175
+ throw new Error(`blob view hash mismatch: ${hit.sha256}`);
176
+ if (root !== null && (fs.realpathSync.native(file) !== path.join(fs.realpathSync.native(path.dirname(file)), path.basename(file))
177
+ || !fs.readdirSync(path.dirname(file)).includes(path.basename(file))))
178
+ throw new Error(`blob view path is linked or has a different spelling: ${file}`);
179
+ return file;
180
+ }
181
+
182
+ function regularParents(file, { strict = false } = {}) {
183
+ let cursor = path.dirname(file);
184
+ while (true) {
185
+ const stat = strict ? fs.lstatSync(cursor, { throwIfNoEntry: false }) : (fs.existsSync(cursor) ? fs.lstatSync(cursor) : null);
186
+ if (stat) {
187
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`blob view parent is not a regular directory: ${cursor}`);
188
+ const parent = path.dirname(cursor);
189
+ if (strict && parent !== cursor
190
+ && (fs.realpathSync.native(cursor) !== path.join(fs.realpathSync.native(parent), path.basename(cursor))
191
+ || !fs.readdirSync(parent).includes(path.basename(cursor))))
192
+ throw new Error(`blob read parent is linked or has a different spelling: ${cursor}`);
193
+ }
194
+ const parent = path.dirname(cursor);
195
+ if (parent === cursor) return;
196
+ cursor = parent;
197
+ }
198
+ }
199
+
200
+ function bundleEntries(manifest) {
201
+ if (!manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) throw new TypeError('blob bundle files must be a mapping');
202
+ const entries = Object.entries(manifest.files);
203
+ const names = new Set();
204
+ for (const [rel, sha] of entries) {
205
+ const parts = rel.split('/');
206
+ if (!rel || rel.includes('\\') || path.posix.isAbsolute(rel) || path.win32.isAbsolute(rel)
207
+ || parts.some(part => !part || part === '.' || part === '..' || /[<>:"|?*\x00-\x1f]/.test(part) || /[. ]$/.test(part)
208
+ || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)) || parts[0] === '.complete')
209
+ throw new TypeError(`unsafe blob bundle path: ${rel}`);
210
+ assertSha(sha);
211
+ const key = rel.toLowerCase();
212
+ if (names.has(key)) throw new TypeError(`blob bundle path collision: ${rel}`);
213
+ names.add(key);
214
+ }
215
+ for (const name of names) {
216
+ const parts = name.split('/');
217
+ for (let index = 1; index < parts.length; index += 1) if (names.has(parts.slice(0, index).join('/')))
218
+ throw new TypeError(`blob bundle file is also a directory: ${name}`);
219
+ }
220
+ return entries;
221
+ }
222
+
223
+ function verifiedBundle(dir, entries, { strict = false } = {}) {
224
+ regularParents(path.join(dir, '.complete'), { strict });
225
+ if (!fs.existsSync(dir)) return false;
226
+ const expected = new Map(entries);
227
+ const seen = new Set();
228
+ const walk = current => {
229
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
230
+ const file = path.join(current, entry.name);
231
+ const rel = slash(path.relative(dir, file));
232
+ if (entry.isSymbolicLink()) throw new Error(`blob bundle view contains a link: ${rel}`);
233
+ if (strict && fs.realpathSync.native(file) !== path.join(fs.realpathSync.native(current), entry.name))
234
+ throw new Error(`blob bundle view contains a redirected path: ${rel}`);
235
+ if (entry.isDirectory()) { walk(file); continue; }
236
+ if (!entry.isFile()) throw new Error(`blob bundle view contains a special file: ${rel}`);
237
+ if (rel === '.complete') continue;
238
+ if (!expected.has(rel) || digest(fs.readFileSync(file)) !== expected.get(rel)) throw new Error(`blob bundle view hash mismatch: ${rel}`);
239
+ seen.add(rel);
240
+ }
241
+ };
242
+ walk(dir);
243
+ return seen.size === entries.length && fs.existsSync(path.join(dir, '.complete'));
244
+ }
245
+
246
+ const filesUnder = (dir, out = []) => {
247
+ for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
248
+ const full = path.join(dir, e.name);
249
+ if (e.isDirectory()) filesUnder(full, out); else if (e.isFile()) out.push(full);
250
+ }
251
+ return out;
252
+ };
253
+
254
+ /** Put every file under `dir` and its manifest in the blob store; returns the manifest sha. */
255
+ export function putBundle(dir, { mediaTypeOf = () => 'application/octet-stream' } = {}) {
256
+ const files = {};
257
+ for (const abs of filesUnder(dir)) files[slash(path.relative(dir, abs))] = putBlob(abs, { mediaType: mediaTypeOf(abs) }).sha;
258
+ return putBlob(Buffer.from(JSON.stringify({ schema: BUNDLE_SCHEMA, files }, null, 2)), { mediaType: 'application/json' }).sha;
259
+ }
260
+
261
+ /** The manifest of a bundle: {schema, files: {rel: sha}} or null. */
262
+ function bundleManifest(ref, { db = null, root = null } = {}) {
263
+ const hit = resolveBlob(ref, { db, root });
264
+ if (!hit) return null;
265
+ const bytes = getBlob(hit.sha256, { root });
266
+ let doc;
267
+ try { doc = JSON.parse(bytes.toString('utf8')); } catch { return null; }
268
+ return doc?.schema === BUNDLE_SCHEMA ? doc : null;
269
+ }
270
+
271
+ /** The bundle as a directory beside the store (cached by sha), or null when it or one file is missing. */
272
+ export function bundleDir(ref, { db = null, root = null } = {}) {
273
+ const sha = shaOfRef(ref, { db });
274
+ const manifest = sha ? bundleManifest(sha, { root }) : null;
275
+ if (!manifest) return null;
276
+ const entries = bundleEntries(manifest);
277
+ const members = [];
278
+ for (const [rel, fileSha] of entries) {
279
+ try { members.push([rel, getBlob(fileSha, { root })]); }
280
+ catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
281
+ }
282
+ const dir = path.join(viewRoot(root), 'bundles', sha);
283
+ if (verifiedBundle(dir, entries, { strict: root !== null })) return dir;
284
+ if (fs.existsSync(dir)) throw new Error(`blob bundle view is incomplete: ${sha}`);
285
+ regularParents(dir, { strict: root !== null });
286
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
287
+ const staging = fs.mkdtempSync(path.join(path.dirname(dir), `.${sha}-`));
288
+ try {
289
+ for (const [rel, bytes] of members) {
290
+ const to = path.resolve(staging, ...rel.split('/'));
291
+ const relative = path.relative(staging, to);
292
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`unsafe blob bundle target: ${rel}`);
293
+ fs.mkdirSync(path.dirname(to), { recursive: true });
294
+ fs.writeFileSync(to, bytes, { flag: 'wx' });
295
+ }
296
+ fs.writeFileSync(path.join(staging, '.complete'), sha, { flag: 'wx' });
297
+ try { fs.renameSync(staging, dir); }
298
+ catch (error) { if (!fs.existsSync(dir) || !verifiedBundle(dir, entries, { strict: root !== null })) throw error; }
299
+ } finally {
300
+ if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
301
+ }
302
+ if (!verifiedBundle(dir, entries, { strict: root !== null })) throw new Error(`blob bundle publication is incomplete: ${sha}`);
303
+ return dir;
304
+ }
305
+
306
+ /**
307
+ * The readable file of one record asset: a product asset kept in the tree ({path}, a ui direction the owner accepted,
308
+ * decision Q2) resolves against the record's directory; agent output ({artifact?, sha256, name}) resolves in the blob
309
+ * store as a copy carrying the extension of its artifact name. Null when neither is readable.
310
+ */
311
+ export function assetFileOf(recordDir, asset, { db = null, root = null } = {}) {
312
+ if (!asset || typeof asset !== 'object') return null;
313
+ if (typeof asset.path === 'string' && asset.path) { const file = path.resolve(recordDir, asset.path); return fs.existsSync(file) ? file : null; }
314
+ return blobAsFile(asset, { ext: path.extname(String(asset.name ?? '')).toLowerCase(), db, root });
315
+ }
@@ -0,0 +1,83 @@
1
+ // Ledger path identity and ownership resolution; connection policy and caches remain in ledger.mjs.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import {sha256} from '../digest.mjs';
6
+ import {isUnderTempDir,localProjectsRoot,readMachine} from './machine.mjs';
7
+ import {isSpecRun} from '../../scripts/lib/env.mjs';
8
+ import {pathKey} from '../../scripts/lib/path-key.mjs';
9
+ /** Overrides the projects root (the directory holding <ledger_id>/runtime.sqlite) for this process tree; narrower than machine-db.mjs LOCAL_ROOT_ENV, which this still honors through starciLocalRoot when unset. */
10
+ export const PROJECTS_ROOT_ENV='STARCI_PROJECTS_ROOT';
11
+ const normDir=file=>pathKey(file);
12
+ /** %LOCALAPPDATA%/StarCi/projects (starciLocalRoot, itself overridable by STARCI_LOCAL_ROOT; a node --test process tree gets one under the OS temp directory). */
13
+ export const projectsRootFor=(env=process.env)=>{
14
+ if(env[PROJECTS_ROOT_ENV])return path.resolve(env[PROJECTS_ROOT_ENV]);
15
+ const root=localProjectsRoot(env);
16
+ if(isSpecRun(env)&&!isUnderTempDir(root,{env}))return path.join(os.tmpdir(),'starci-test-projects');
17
+ return root;
18
+ };
19
+ /** Repository identity: resolved/realpath, forward slashes, case-folded only on Windows. */
20
+ export const repoRootKey=repoRoot=>{let root=path.resolve(repoRoot);try{root=fs.realpathSync.native(root);}catch{}return normDir(root);};
21
+ /**
22
+ * The ledger id of a repository root until the machine registry (a3-2 machine.ledgers) resolves it: a name-based
23
+ * UUID of the normalized root, so the same root always names the same ledger and no side registry is needed.
24
+ */
25
+ const ledgerIdFromKey=key=>{
26
+ const h=sha256(`starci-ledger:${key}`);
27
+ return `${h.slice(0,8)}-${h.slice(8,12)}-5${h.slice(13,16)}-${(8|(parseInt(h[16],16)&3)).toString(16)}${h.slice(17,20)}-${h.slice(20,32)}`;
28
+ };
29
+ export const ledgerIdForRepo=repoRoot=>ledgerIdFromKey(repoRootKey(repoRoot));
30
+ /** The registered runtime.sqlite of `repoRoot` in machine.ledgers (a3-2 resolveLedger), or null. */
31
+ function registeredLedgerFile(root,env,openReader){
32
+ let row;try{row=readMachine(m=>m.resolveLedger({repoRoot:root}),null,{env});}catch{return null;}
33
+ const file=row&&row.state!=='retired'&&row.file?path.resolve(row.file):null;
34
+ if(file&&fs.existsSync(file))assertLedgerRoot(file,root,openReader);
35
+ return file;
36
+ }
37
+ function assertLedgerRoot(file,root,openReader){
38
+ const db=openReader(file);let own;try{own=db.prepare("SELECT value FROM meta WHERE key='repo_root'").get()?.value;}finally{db.close();}
39
+ if(!own||repoRootKey(own)!==repoRootKey(root))throw Object.assign(Error(`ledger-root-mismatch: ${file} belongs to another or unverified repository; preserve the database and resolve the owner binding before migration`),{code:'STARCI_LEDGER_ROOT_MISMATCH'});
40
+ }
41
+ function unregisteredLedgerFile(root,env,openReader){
42
+ const base=projectsRootFor(env),current=path.join(base,ledgerIdForRepo(root),'runtime.sqlite');
43
+ if(fs.existsSync(current)){assertLedgerRoot(current,root,openReader);return current;}
44
+ // Preserve a pre-fix POSIX binding only when its own exact repo_root proves
45
+ // this repository. Never silently move, rename or merge an old folded store.
46
+ const legacy=path.join(base,ledgerIdFromKey(repoRootKey(root).toLowerCase()),'runtime.sqlite');
47
+ if(legacy!==current&&fs.existsSync(legacy)){assertLedgerRoot(legacy,root,openReader);return legacy;}
48
+ return current;
49
+ }
50
+ /** Resolve the registry or exact owned store; the ledger owner supplies its verified read-only opener. */
51
+ export function resolveLedgerFile(root,{env,openReader}){
52
+ return registeredLedgerFile(root,env,openReader)??unregisteredLedgerFile(root,env,openReader);
53
+ }
54
+
55
+ const SYNTHETIC_LEDGER_ID=/^00000000-0000-4000-8000-[0-9a-f]{12}$/;
56
+ const fixtureRefused=message=>{throw new TypeError(`ledger-fixture-refused: ${message}`);};
57
+ /** Validate a fresh, initialization-only sample identity. Samples carry portable metadata,
58
+ * never a repository/machine binding; the ordinary project resolver is not bypassed. */
59
+ export function ledgerFixtureInit(fixture,{file,repoRoot,product,machine,mapped,checkpointer,now}){
60
+ if(fixture===null){
61
+ if(typeof file==='string'&&SYNTHETIC_LEDGER_ID.test(path.basename(path.dirname(path.resolve(file)))))fixtureRefused('synthetic identity is reserved for a sample');
62
+ return null;
63
+ }
64
+ if(!fixture||typeof fixture!=='object'||Array.isArray(fixture)
65
+ ||![Object.prototype,null].includes(Object.getPrototypeOf(fixture))
66
+ ||Object.keys(fixture).sort().join(',')!=='blobRoot,createdAt,ledgerId')fixtureRefused('needs exactly ledgerId, createdAt and blobRoot');
67
+ if(typeof fixture.ledgerId!=='string'||!SYNTHETIC_LEDGER_ID.test(fixture.ledgerId))fixtureRefused('ledgerId must be a synthetic UUID');
68
+ if(!Number.isSafeInteger(fixture.createdAt)||fixture.createdAt<=0||!Number.isFinite(new Date(fixture.createdAt).getTime()))fixtureRefused('createdAt must be a positive safe epoch millisecond');
69
+ const root=fixture.blobRoot;
70
+ if(typeof root!=='string'||!root||root.includes('\\')||root.trim()!==root||/[<>:"|?*\x00-\x1f]/.test(root)
71
+ ||path.posix.isAbsolute(root)||root.split('/').some(p=>!p||p==='.'||p==='..'))fixtureRefused('blobRoot must be a portable relative path');
72
+ if(repoRoot!==null||product!==null||machine!==null||mapped)fixtureRefused('a sample cannot bind a repository, product, machine or cached project path');
73
+ if(!checkpointer||now!==Date.now)fixtureRefused('initialization owns its frozen clock and requires a checkpointer');
74
+ if(typeof file!=='string'||!path.isAbsolute(file)||path.basename(file)!=='runtime.sqlite')fixtureRefused('file must be an explicit absolute runtime.sqlite');
75
+ for(const suffix of ['','-wal','-shm'])if(fs.lstatSync(file+suffix,{throwIfNoEntry:false}))fixtureRefused('file and WAL/SHM must be absent');
76
+ const parent=path.dirname(file),stat=fs.lstatSync(parent,{throwIfNoEntry:false});
77
+ if(!stat?.isDirectory()||stat.isSymbolicLink()||fs.realpathSync.native(parent)!==path.resolve(parent))fixtureRefused('sample parent must be an existing physical directory');
78
+ return Object.freeze({...fixture,marker:'starci/basic-runtime-fixture@1'});
79
+ }
80
+ /** Refuse sample bytes as an operational ledger before opening a writer or beginning a transaction. */
81
+ export function assertOperationalLedger(meta){
82
+ if(meta.fixture||SYNTHETIC_LEDGER_ID.test(meta.ledger_id??''))throw new Error('ledger-fixture-read-only: preserve the sample; initialize a new live project store');
83
+ }