@sigrun/sigrun 0.2.0 → 0.2.1

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.
@@ -34,8 +34,14 @@
34
34
  * request before handlers run) + a non-empty user header; a
35
35
  * directory miss auto-provisions (`source:"proxy"`, admin:false)
36
36
  * unless `autoProvision:false` → 403 `user_not_provisioned`.
37
+ * Every proxy rejection (401/403) emits ONE sanitized `warn` naming
38
+ * the socket peer + a short reason (release-readiness Task 1, spec
39
+ * §3 — never tokens, cookies, identity-header VALUES, query
40
+ * strings, or the full URL; a request presenting a Bearer/cookie
41
+ * credential is a token attempt and stays silent).
37
42
  * `token`: Bearer or the `sigrun_session` cookie → `tokens.verify`;
38
- * the token's user must exist in the directory, else 401.
43
+ * the token's user must exist in the directory, else 401 (no log —
44
+ * token failures are not proxy diagnostics).
39
45
  * 6. **Empty list** (the naive workstation path): the trimmed
40
46
  * `X-Sigrun-User` header verbatim, `admin: true`, method `"none"`.
41
47
  */
@@ -184,6 +190,22 @@ function isPublicPath(request, ctx) {
184
190
  }
185
191
  return false;
186
192
  }
193
+ /**
194
+ * The proxy-auth rejection diagnostic (release-readiness Task 1, spec §3):
195
+ * ONE `warn` per rejected request, naming the socket peer, the request
196
+ * method, a short reason, and — only when Fastify already resolved it —
197
+ * the ROUTE TEMPLATE (`routeOptions.url`, never the raw URL). The line
198
+ * must never carry API tokens, cookies, identity-header VALUES, query
199
+ * strings, or the full request URL; a header NAME appears only where the
200
+ * reason itself is "that configured header was absent/wrong" (its value
201
+ * never does). This is diagnostic logging, not evidence that the
202
+ * configured allowlist is correct.
203
+ */
204
+ function warnProxyRejected(request, reason) {
205
+ const peer = request.socket.remoteAddress ?? "unknown";
206
+ const route = request.routeOptions.url;
207
+ console.warn(`sigrun: proxy auth rejected ${request.method} request from ${peer} (${reason})${route === undefined ? "" : ` on ${route}`}`);
208
+ }
187
209
  /**
188
210
  * Stage 5a (proxy): resolve the forward-auth headers, or reply with the
189
211
  * rejection. The secret header is DELETED from the request before any
@@ -191,7 +213,24 @@ function isPublicPath(request, ctx) {
191
213
  */
192
214
  async function resolveProxy(request, ctx) {
193
215
  const { proxy, adminGroups } = ctx.config.auth;
216
+ // A request presenting a Bearer/cookie CREDENTIAL is attempting token
217
+ // auth (or riding the stage-3 service token), not claiming a proxy
218
+ // identity: when the token method is ALSO configured, suppress the
219
+ // rejection diagnostic — a denied credential is the token method's
220
+ // failure, which never logs (spec §3). The pipeline visits the proxy
221
+ // method FIRST for such a request (a trusted peer presenting a session
222
+ // without proxy headers is a legitimate client shape behind
223
+ // forward-auth), and a "missing secret" warn would fire even when the
224
+ // token then authenticates. In a PROXY-ONLY deployment the suppression
225
+ // does not apply: a rejected credential-bearing request there IS a
226
+ // proxy-method 401 and must log (spec §3 exempts token-auth failures,
227
+ // and no token method exists).
228
+ const attemptingToken = (bearerOf(request) !== undefined || sessionCookieOf(request) !== undefined) &&
229
+ ctx.methods.includes("token");
194
230
  if (!ipAllowed(request.socket.remoteAddress, proxy.trustedProxies)) {
231
+ if (!attemptingToken) {
232
+ warnProxyRejected(request, "untrusted proxy");
233
+ }
195
234
  return { status: 401 };
196
235
  }
197
236
  if (proxy.secret !== undefined && proxy.secretHeader !== undefined) {
@@ -199,12 +238,19 @@ async function resolveProxy(request, ctx) {
199
238
  const presented = headerOf(request, name);
200
239
  delete request.headers[name];
201
240
  if (presented === undefined || !timingSafeStringEqual(presented, proxy.secret)) {
241
+ if (!attemptingToken) {
242
+ warnProxyRejected(request, `missing or wrong shared secret (${proxy.secretHeader} header)`);
243
+ }
202
244
  return { status: 401 };
203
245
  }
204
246
  }
205
247
  const user = headerOf(request, proxy.userHeader);
206
- if (user === undefined)
248
+ if (user === undefined) {
249
+ if (!attemptingToken) {
250
+ warnProxyRejected(request, `missing identity header (${proxy.userHeader})`);
251
+ }
207
252
  return { status: 401 };
253
+ }
208
254
  const groups = headerOf(request, proxy.groupsHeader)
209
255
  ?.split(",")
210
256
  .map((group) => group.trim())
@@ -212,12 +258,15 @@ async function resolveProxy(request, ctx) {
212
258
  const { users } = ctx.opened.identity;
213
259
  // The invalid-username case (a proxy header that can't be a directory
214
260
  // user) is a loud 403, not a 500: the proxy vouched for an identity
215
- // this server can never represent.
261
+ // this server can never represent. The warn's reason never carries the
262
+ // claimed value (spec §3: no identity-header values).
216
263
  if (!/^[a-z0-9][a-z0-9._-]*$/.test(user)) {
264
+ warnProxyRejected(request, "unusable identity claim");
217
265
  return { status: 403, error: "invalid_username" };
218
266
  }
219
267
  const existing = users.get(user);
220
268
  if (existing === undefined && !proxy.autoProvision) {
269
+ warnProxyRejected(request, "untrusted identity source");
221
270
  return { status: 403, error: "user_not_provisioned" };
222
271
  }
223
272
  let record;
@@ -8,8 +8,8 @@
8
8
  * only).
9
9
  *
10
10
  * Layout: each entry is one file `<sha256(u)>.<variant>` beside ONE
11
- * `index.json` (the metadata table: type, size, added-at, last-hit,
12
- * pin). The index is written atomically on every mutation, so a re-open
11
+ * `index.json` (the metadata table: type, size, added-at, clamped
12
+ * max-age, last-hit, pin). The index is written atomically on every mutation, so a re-open
13
13
  * (the same cache dir, a new process) serves the prior entries.
14
14
  *
15
15
  * Eviction never deletes a file currently being STREAMED or WRITTEN
@@ -74,7 +74,8 @@ export class ImageCache {
74
74
  return;
75
75
  }
76
76
  for (const [key, entry] of Object.entries(parsed.entries)) {
77
- this.entries.set(key, { ...entry, pins: 0 });
77
+ // Pre-max-age indexes used the original default: one hour.
78
+ this.entries.set(key, { ...entry, maxAge: entry.maxAge ?? 3600, pins: 0 });
78
79
  }
79
80
  }
80
81
  /** One hit, or `undefined` on a miss. `now` stamps the LRU order. */
@@ -99,12 +100,17 @@ export class ImageCache {
99
100
  entry.pins += 1;
100
101
  await this.persist();
101
102
  const stream = createReadStream(file);
103
+ let unpinned = false;
102
104
  const unpin = () => {
105
+ // Node emits close after error: one hit releases one pin.
106
+ if (unpinned)
107
+ return;
108
+ unpinned = true;
103
109
  entry.pins -= 1;
104
110
  };
105
111
  stream.once("close", unpin);
106
112
  stream.once("error", unpin);
107
- return { type: entry.type, stream };
113
+ return { type: entry.type, addedAt: entry.addedAt, maxAge: entry.maxAge, stream };
108
114
  }
109
115
  /**
110
116
  * Store one fetched image. The write is staged and renamed (atomic:
@@ -112,7 +118,7 @@ export class ImageCache {
112
118
  * is pinned for the write's duration — the eviction pass a same-key
113
119
  * overwrite triggers can never remove the file mid-rename.
114
120
  */
115
- async put(u, variant, type, body, now) {
121
+ async put(u, variant, type, body, now, maxAge = 3600) {
116
122
  await this.open();
117
123
  if (this.closed)
118
124
  return;
@@ -122,6 +128,7 @@ export class ImageCache {
122
128
  type,
123
129
  size: body.byteLength,
124
130
  addedAt: at,
131
+ maxAge,
125
132
  lastHit: at,
126
133
  pins: 1,
127
134
  };
@@ -180,6 +187,7 @@ export class ImageCache {
180
187
  type: entry.type,
181
188
  size: entry.size,
182
189
  addedAt: entry.addedAt,
190
+ maxAge: entry.maxAge,
183
191
  lastHit: entry.lastHit,
184
192
  },
185
193
  ])),
@@ -93,8 +93,9 @@ import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
93
93
  * then the configured methods — the resolved principal stamps
94
94
  * `SessionMeta.owner` at create/fork/import.
95
95
  * @param options.listenHost the host the server will bind (fallback
96
- * `"0.0.0.0"` when omitted; `serve()` passes the dual-stack `::`);
97
- * the pipeline's Host check and the default method list
96
+ * `"0.0.0.0"` when omitted; `serve()` passes its ONE resolved host —
97
+ * `options.host` → `SIGRUN_LISTEN_HOST` → the dual-stack `::`, serving
98
+ * spec §1); the pipeline's Host check and the default method list
98
99
  * (`effectiveMethods`) both derive from it.
99
100
  * @param contributedBundles the extension-contributed webui-bundle registry
100
101
  * (build-and-webui Task 6 threads it here from boot; the serve-time
@@ -299,6 +300,13 @@ export async function createServer(opened, config, contributedBundles = [], opti
299
300
  commandCatalog,
300
301
  listenHost,
301
302
  methods,
303
+ // The listener record (release-readiness Task 1, serving spec §2):
304
+ // host + port are the server construction's inputs; `families`
305
+ // starts EMPTY (never listened) and `serve()` fills it in place
306
+ // with the measured families after `app.listen` resolves — the
307
+ // meta route reads whichever state exists at request time, never
308
+ // probing itself. A hand-built test server serves intent only.
309
+ listener: { host: listenHost, port: config.port, families: [] },
302
310
  env,
303
311
  // U4 §13.2: the harness's settings-changed fan-out (hub publish +
304
312
  // extension onSettingsChanged + the scheduler re-arm).
@@ -78,6 +78,10 @@ export function startNotifications(ctx) {
78
78
  title: base.title,
79
79
  body: base.body,
80
80
  ...(url === undefined ? {} : { url }),
81
+ // The settled outcome rides ONLY run.settled events — the
82
+ // other variants never gain the field (the type is the union;
83
+ // the conditional spread keeps the wire shape exact).
84
+ ...(base.outcome === undefined ? {} : { outcome: base.outcome }),
81
85
  };
82
86
  for (const target of targets) {
83
87
  // Fire-and-forget per transport call: one notifier's failure or
@@ -123,7 +127,7 @@ export function startNotifications(ctx) {
123
127
  }, recipients);
124
128
  };
125
129
  /** run.settled → the owner, gated by the live `notifications.runSettled` mode. */
126
- const handleRunSettled = async (conversationId) => {
130
+ const handleRunSettled = async (conversationId, outcome, reason) => {
127
131
  const mode = opened.settings.read().notifications?.runSettled ?? "unwatched";
128
132
  if (mode === "never")
129
133
  return;
@@ -134,13 +138,26 @@ export function startNotifications(ctx) {
134
138
  (watchers.get(conversationId) ?? 0) > 0) {
135
139
  return;
136
140
  }
141
+ // Translate the EXISTING wire outcome/reason straight across (no
142
+ // second answer read, no inferred status). An unanswered run never
143
+ // titles itself "Reply ready": the reason is the run's own, with
144
+ // the bare status as the fallback for a reason-less settle.
145
+ const settledOutcome = outcome === "answered"
146
+ ? { status: "answered" }
147
+ : { status: "unanswered", reason: reason ?? "unanswered" };
148
+ const settledReason = reason ?? "unanswered";
137
149
  deliver({
138
150
  type: "run.settled",
139
151
  conversationId,
140
152
  jobId: undefined,
141
153
  callId: undefined,
142
- title: "Reply ready",
143
- body: displayName(meta, conversationId),
154
+ title: outcome === "answered"
155
+ ? "Reply ready"
156
+ : "Run ended without a reply",
157
+ body: outcome === "answered"
158
+ ? displayName(meta, conversationId)
159
+ : `${displayName(meta, conversationId)} — ${settledReason}`,
160
+ outcome: settledOutcome,
144
161
  }, [meta?.owner]);
145
162
  };
146
163
  /**
@@ -177,7 +194,7 @@ export function startNotifications(ctx) {
177
194
  void bus.runBounded("notifications interaction.pending", () => handleInteraction(event.conversationId, event.callId));
178
195
  }
179
196
  else if (event.type === "run.settled") {
180
- void bus.runBounded("notifications run.settled", () => handleRunSettled(event.conversationId));
197
+ void bus.runBounded("notifications run.settled", () => handleRunSettled(event.conversationId, event.outcome, event.reason));
181
198
  }
182
199
  else if (event.type === "job.changed") {
183
200
  void bus.runBounded("notifications job.changed", () => {
@@ -17,9 +17,10 @@
17
17
  * POSTed as the raw text body; the route's `bodyLimit` is the §11.1
18
18
  * 192 KiB rule (an over-limit body is Fastify's 413 `too_large`).
19
19
  *
20
- * Fetches go through the on-disk LRU cache (`<cacheDir>/images/`,
21
- * `images/cache.ts`): a hit re-serves without an upstream call, and
22
- * concurrent requests for one URL share ONE in-flight upstream fetch
20
+ * Fetches go through the on-disk LRU cache: a hit fresher than its
21
+ * clamped max-age re-serves without an upstream call; an older hit
22
+ * revalidates, and a refusal is never replaced by stale bytes.
23
+ * Concurrent requests for one URL share ONE in-flight upstream fetch
23
24
  * (Review Focus 4). Failures are never cached. Responses carry the
24
25
  * sniffed Content-Type, `X-Content-Type-Options: nosniff`, and
25
26
  * `Cache-Control: private, max-age=<upstream hint clamped to 1 h–7 d>`.
@@ -114,13 +115,17 @@ export function registerImageRoutes(app, ctx) {
114
115
  const inflight = new Map();
115
116
  /** Fetch one URL through the cache, sharing in-flight work. */
116
117
  async function fetchThroughCache(u, variant) {
117
- const hit = await cache.get(u, variant);
118
- if (hit !== undefined) {
118
+ const now = deps.now();
119
+ const hit = await cache.get(u, variant, now);
120
+ if (hit !== undefined && now - hit.addedAt < hit.maxAge * 1000) {
119
121
  const chunks = [];
120
122
  for await (const chunk of hit.stream)
121
123
  chunks.push(chunk);
122
- return { bytes: Buffer.concat(chunks), type: hit.type };
124
+ return { bytes: Buffer.concat(chunks), type: hit.type, maxAge: hit.maxAge };
123
125
  }
126
+ // A stale hit is never served: close its pin and revalidate.
127
+ if (hit !== undefined)
128
+ hit.stream.destroy();
124
129
  const inflightKey = `${variant}${u}`;
125
130
  const existing = inflight.get(inflightKey);
126
131
  if (existing !== undefined)
@@ -128,11 +133,13 @@ export function registerImageRoutes(app, ctx) {
128
133
  const work = (async () => {
129
134
  const fetched = await fetchImage(u, deps);
130
135
  // Cache the original variant first — `as=png` requests cache
131
- // BOTH variants (the conversion reads the fetched bytes).
132
- await cache.put(u, "orig", fetched.type, fetched.bytes);
136
+ // BOTH variants (the conversion reads the fetched bytes). The
137
+ // clamped hint is BOTH the response header and the cache TTL.
138
+ const maxAge = clampedMaxAge(fetched.maxAge);
139
+ await cache.put(u, "orig", fetched.type, fetched.bytes, deps.now(), maxAge);
133
140
  if (variant === "png") {
134
141
  const converted = await toPng(fetched.bytes);
135
- await cache.put(u, "png", "png", converted);
142
+ await cache.put(u, "png", "png", converted, deps.now(), maxAge);
136
143
  return {
137
144
  bytes: converted,
138
145
  type: "png",
@@ -52,7 +52,7 @@ export function registerMeRoutes(app, ctx) {
52
52
  const settings = body;
53
53
  await stores.settings.set(settings);
54
54
  const { slug } = request.params;
55
- opened.userServices.settingsChanged(slug, request.principal.user);
55
+ ctx.publishSettingsChanged({ extension: slug }, Object.keys(settings), request.principal.user);
56
56
  const response = { settings, pendingRestart: [] };
57
57
  return response;
58
58
  });
@@ -39,6 +39,10 @@ export function registerMetaRoutes(app, ctx) {
39
39
  version: SIGRUN_VERSION,
40
40
  harnessVersion: HARNESS_VERSION,
41
41
  schemaVersion: SCHEMA_VERSION,
42
+ // The retained listener facts (release-readiness Task 1, serving
43
+ // spec §2): measured once at listen, projected verbatim — this
44
+ // route never probes (a pure synchronous projection).
45
+ listener: ctx.listener,
42
46
  extensions: opened.extensionOutcomes.map((outcome) => {
43
47
  const workers = workerStatuses.get(outcome.name);
44
48
  const surfaces = opened.extensionSurfaces.find((binding) => binding.extension === outcome.name);
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-10-28
11
+ ### Added
12
+ - Additive listener metadata on the meta response: host, port, and
13
+ measured address families.
14
+
10
15
  ## [0.2.0] - 2026-10-27
11
16
  ### Added
12
17
  - Surface and block wire types extended for the U5/U6/U7 workstreams:
@@ -403,11 +403,38 @@ export type WorkerStatus = {
403
403
  readonly lastError?: string;
404
404
  };
405
405
  /** The `GET /v1/meta` payload (spec §5; the health block is Task 9e). */
406
+ /**
407
+ * The measured listener facts (release-readiness Task 1, serving spec §2):
408
+ * captured ONCE when the listener goes live (never probed per request) and
409
+ * retained on the server context; `GET /v1/meta` projects them verbatim.
410
+ */
411
+ export interface ListenerInfo {
412
+ /** The effective host string supplied to `listen` — never a resolved peer. */
413
+ readonly host: string;
414
+ /** The configured/selected port the listener bound. */
415
+ readonly port: number;
416
+ /**
417
+ * The address families the listener accepts, in stable `ipv4`, `ipv6`
418
+ * order. Literal binds report the bound socket's family; the `::`
419
+ * wildcard reports exactly the loopback families a startup reachability
420
+ * check connected on (measured, never inferred from the wildcard
421
+ * spelling). Empty on a server that never listened (a hand-built test
422
+ * server projecting intent only).
423
+ */
424
+ readonly families: readonly ("ipv4" | "ipv6")[];
425
+ }
406
426
  export interface MetaResponse {
407
427
  /** The sigrun server's own version (the package manifest). */
408
428
  readonly version: string;
409
429
  readonly harnessVersion: string;
410
430
  readonly schemaVersion: string;
431
+ /**
432
+ * The listener facts (release-readiness Task 1, serving spec §2). A
433
+ * REQUIRED field on this type; OLDER SERVERS (≤0.2.0) omit it on the
434
+ * wire, so clients consuming it must tolerate its absence during
435
+ * mixed-version upgrades.
436
+ */
437
+ readonly listener: ListenerInfo;
411
438
  readonly extensions: readonly {
412
439
  readonly name: string;
413
440
  readonly status: string;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/shared",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "sigrun shared leaf package \u2014 theme tokens, turn/conversation shaping, and the channel wire types (pure leaf, zero dependencies)",
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/sigrun",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Durable personal assistant on pi-durable",
5
5
  "type": "module",
6
6
  "exports": {
@@ -30,7 +30,7 @@
30
30
  "test": "vitest run",
31
31
  "typecheck": "tsc --noEmit",
32
32
  "build:shared": "npm --workspace @sigrun/shared run build",
33
- "build": "npm run build:shared && tsc -p tsconfig.build.json && tsc -p tsconfig.extension.json && node -e \"require('node:fs').rmSync('dist/extension.js',{force:true})\"",
33
+ "build": "npm run build:shared && tsc -p tsconfig.build.json && tsc -p tsconfig.extension.json && node scripts/stamp-block-build.mjs && node -e \"require('node:fs').rmSync('dist/extension.js',{force:true})\"",
34
34
  "build:webui": "npm --workspace @sigrun/webui run build",
35
35
  "typecheck:webui": "npm --workspace @sigrun/webui run typecheck",
36
36
  "test:e2e": "npm run build:webui && npm --workspace @sigrun/webui run test:e2e",
@@ -45,11 +45,11 @@
45
45
  "@earendil-works/pi-tui": "^1.3.0",
46
46
  "@fastify/static": "^10.1.7",
47
47
  "@sigrun/ext-codemode": "0.1.1",
48
- "@sigrun/ext-gateway": "0.1.1",
48
+ "@sigrun/ext-gateway": "0.1.2",
49
49
  "@sigrun/ext-mcp": "0.2.1",
50
50
  "@sigrun/ext-tool-search": "0.1.1",
51
51
  "@sigrun/oauth": "0.1.1",
52
- "@sigrun/shared": "0.2.0",
52
+ "@sigrun/shared": "0.2.1",
53
53
  "croner": "^10.0.1",
54
54
  "fastify": "^5.12.7",
55
55
  "sharp": "^0.35.5",
@@ -59,9 +59,9 @@
59
59
  "@sigrun/shared"
60
60
  ],
61
61
  "optionalDependencies": {
62
- "@sigrun/sandbox-linux-arm64": "0.2.0",
63
- "@sigrun/sandbox-linux-x64": "0.2.0",
64
- "@sigrun/tui": "0.2.0"
62
+ "@sigrun/sandbox-linux-arm64": "0.2.1",
63
+ "@sigrun/sandbox-linux-x64": "0.2.1",
64
+ "@sigrun/tui": "0.2.1"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@types/node": "^26.6.4",
@@ -90,8 +90,7 @@
90
90
  "THIRD_PARTY_NOTICES.md",
91
91
  "README.md",
92
92
  "LICENSE",
93
- "CHANGELOG.md",
94
- "CONTEXT.md"
93
+ "CHANGELOG.md"
95
94
  ],
96
95
  "allowScripts": {
97
96
  "@google/genai@2.21.0": true,