Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/p556-linked-state-save-notice.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A save over a team file that is a link no longer writes through it: the name becomes a regular file of this home's own, the file the link pointed at keeps every byte and its last write time, and the window says so once (`Saved as a separate file; the file the link pointed at was not changed.`) — the count carries the host process's generation, so a restart cannot swallow the first notice, and a signed-in deployment receives it too.
38 changes: 38 additions & 0 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,9 @@ var ZH = {
"{bot} asked for {name}": "{bot} 请求了 {name}",
"Allowed {bot} to use {name}": "已允许 {bot} 使用 {name}",
"Saved {name}": "已保存 {name}",
// P-556: the store replaced a linked `state.json` with a file of this home's own and never wrote
// through the link, so the file the person linked keeps every byte.
"Saved as a separate file; the file the link pointed at was not changed.": "已保存为独立文件,原链接指向的内容未改动。",
Canceled: "已取消",
Done: "完成",
// #1855: the one collapsed group that holds a Project's finished cards.
Expand Down Expand Up @@ -21984,6 +21987,39 @@ function writeMode(mode, storage = globalThis.localStorage) {
}
}

// src/client/state-link-notice.js
var LINK_REPLACED_NOTICE = "Saved as a separate file; the file the link pointed at was not changed.";
var stateLinkCount = (value) => {
const field = value?.stateFileLinkReplaced;
if (!field || typeof field !== "object") return null;
const gen = typeof field.gen === "string" ? field.gen : "";
const count = Number(field.count);
if (gen === "" || !Number.isFinite(count) || count < 0) return null;
return { gen, count: Math.floor(count) };
};
var stateLinkMemory = (seen) => {
if (!seen || typeof seen !== "object") return null;
const gen = typeof seen.gen === "string" ? seen.gen : "";
if (gen === "") return null;
const count = Number(seen.count);
return { gen, count: Number.isFinite(count) && count > 0 ? Math.floor(count) : 0 };
};
var stateLinkNotice = (value, seen) => {
const known = stateLinkMemory(seen);
const answer = stateLinkCount(value);
if (answer === null) return { seen: known ?? { gen: "", count: 0 }, announce: false };
if (known === null) return { seen: answer, announce: false };
if (answer.gen !== known.gen) {
return answer.count > 0 ? { seen: answer, announce: true } : { seen: { gen: answer.gen, count: 0 }, announce: false };
}
return answer.count > known.count ? { seen: answer, announce: true } : { seen: known, announce: false };
};
var announceStateLink = (value, seen, say) => {
const next = stateLinkNotice(value, seen);
if (next.announce) say?.(LINK_REPLACED_NOTICE);
return next.seen;
};

// src/client/user-message.js
var import_react48 = require("react");
var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1);
Expand Down Expand Up @@ -22398,6 +22434,7 @@ function apply(ctx) {
const refreshed = /* @__PURE__ */ new Set();
let pulls = 0;
let applied = 0;
let linkSeen = null;
let reloading = 0;
const pull = async ({ fresh = false } = {}) => {
const order = ++pulls;
Expand Down Expand Up @@ -22430,6 +22467,7 @@ function apply(ctx) {
live2.pluginTools = value.pluginTools;
}
if (Object.keys(live2).length > 0 && current.ready) roster.set({ ...current, ...live2 });
linkSeen = announceStateLink(value, linkSeen, (text2) => actions.notify(t(text2)));
if (stateAnswerIsStale(value, current, { fresh })) return;
if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map((entry) => entry.id));
roster.set(indexRoster(value));
Expand Down
4 changes: 4 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,10 @@ const ZH = {
'{bot} asked for {name}': '{bot} 请求了 {name}',
'Allowed {bot} to use {name}': '已允许 {bot} 使用 {name}',
'Saved {name}': '已保存 {name}',
// P-556: the store replaced a linked `state.json` with a file of this home's own and never wrote
// through the link, so the file the person linked keeps every byte.
'Saved as a separate file; the file the link pointed at was not changed.':
'已保存为独立文件,原链接指向的内容未改动。',
Canceled: '已取消',
Done: '完成',
// #1855: the one collapsed group that holds a Project's finished cards.
Expand Down
12 changes: 11 additions & 1 deletion core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ import { PROJECTS_NAV_CSS } from './projects-nav.js'
import { STORAGE, migrateStorage } from './storage.js'
import { friendLineNews } from './friend-lines.js'
import { groupsFor, readMode, surfaceGroups, writeMode } from './surface.js'
import { installLocale, language } from './i18n.js'
import { installLocale, language, t } from './i18n.js'
import { announceStateLink } from './state-link-notice.js'
import { noteClientError, noteNavigation, quietly } from './diagnostics.js'
import { ErrorToast, FEEDBACK_CSS } from './feedback.js'
import { ABOUT_CSS } from './about.js'
Expand Down Expand Up @@ -172,6 +173,11 @@ export function apply(ctx) {
// Pulls can overlap (the poll and a navigation); an older answer never wins.
let pulls = 0
let applied = 0
// P-556: the count of saves that replaced a linked `state.json`, as far as this window has told
// the person, with the host generation it belongs to: the count moves no revision, so it is read on
// every answer, `unchanged` included. `null` until the first answer: a window that has just opened
// takes that count as its baseline, so a refresh never replays a notice about an older save.
let linkSeen = null
// A Reload in flight makes the poll stand back. Otherwise the loop's `unchanged` answer can take
// the applied slot first and the full answer the press asked for is dropped as the older one.
let reloading = 0
Expand All @@ -197,6 +203,10 @@ export function apply(ctx) {
// Plugin tool names come with a plugin reload, not a revision; context rows re-read them (#419).
if (value.pluginTools !== undefined && JSON.stringify(value.pluginTools) !== JSON.stringify(current.pluginTools ?? [])) { setPluginTools(value.pluginTools); live.pluginTools = value.pluginTools }
if (Object.keys(live).length > 0 && current.ready) roster.set({ ...current, ...live })
// P-556: a save whose name was a link replaced it with a file of this home's own instead of
// writing through the link. The count rides every answer, the `unchanged` one too, so the
// window can say it once — before the staleness check, which drops the answer's team, not this.
linkSeen = announceStateLink(value, linkSeen, text => actions.notify(t(text)))
if (stateAnswerIsStale(value, current, { fresh })) return
// On a browser with no visit record yet, everything already there counts as seen.
if (Object.keys(seenAt).length === 0) leaveVisits([...value.bots, ...value.rooms].map(entry => entry.id))
Expand Down
61 changes: 61 additions & 0 deletions core/src/client/state-link-notice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// P-556: a save over a linked `state.json` replaces the name with a regular file of this home's own
// and never writes through the link, so the file the person linked keeps every byte and its last
// write time. The host counts those saves and the state answer carries the count together with the
// generation of the host process that made it — a restart starts the count at 0 again — and a window
// that has not said so yet says it once, in the same toast the client shows for confirmations.
// `seen` is what this window has already announced, remembered between polls: `{ gen, count }`.
export const LINK_REPLACED_NOTICE = 'Saved as a separate file; the file the link pointed at was not changed.'

// What one answer says about one host process: the generation that counted, and how many of its saves
// replaced a link. Anything else — a missing field, a bare number, negative or unreadable counts, a
// count with no generation — is not a comparable count, so it answers null and announces nothing.
export const stateLinkCount = (value) => {
const field = value?.stateFileLinkReplaced
if (!field || typeof field !== 'object') return null
const gen = typeof field.gen === 'string' ? field.gen : ''
const count = Number(field.count)
if (gen === '' || !Number.isFinite(count) || count < 0) return null
return { gen, count: Math.floor(count) }
}

// What this window remembers, or null when it remembers nothing: a page that has just loaded has no
// memory, and its first count is a baseline rather than news.
export const stateLinkMemory = (seen) => {
if (!seen || typeof seen !== 'object') return null
const gen = typeof seen.gen === 'string' ? seen.gen : ''
if (gen === '') return null
const count = Number(seen.count)
return { gen, count: Number.isFinite(count) && count > 0 ? Math.floor(count) : 0 }
}

// A count is only comparable inside the process that made it: a new host process starts it at 0, so
// the first save of a restarted host can carry a number the window already passed. Three cases, and
// they are different questions (Security, on review of #2473):
// - no memory (a page that has just loaded): the answer's count becomes the baseline and nothing is
// said, so a refresh never replays a notice about a save the person was already told about;
// - memory under another generation (the host restarted while this window stayed open): the baseline
// resets to 0 — not to the answer's count, which would swallow the very save that came with the
// restart — so `count > 0` says that save once;
// - memory under the same generation: the usual `count > known`, quiet on a repeated `unchanged` and
// on a host that replaced no link.
export const stateLinkNotice = (value, seen) => {
const known = stateLinkMemory(seen)
const answer = stateLinkCount(value)
if (answer === null) return { seen: known ?? { gen: '', count: 0 }, announce: false }
if (known === null) return { seen: answer, announce: false }
if (answer.gen !== known.gen) {
return answer.count > 0
? { seen: answer, announce: true }
: { seen: { gen: answer.gen, count: 0 }, announce: false }
}
return answer.count > known.count ? { seen: answer, announce: true } : { seen: known, announce: false }
}

// One poll's worth of that decision: say the sentence when the count is news, and answer what the
// window now remembers. A window that cannot draw (no `say`) still remembers, so a line it missed
// while it could not draw is not repeated once it can.
export const announceStateLink = (value, seen, say) => {
const next = stateLinkNotice(value, seen)
if (next.announce) say?.(LINK_REPLACED_NOTICE)
return next.seen
}
6 changes: 6 additions & 0 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,12 @@ export function install(rt) {
memoryReviews: alone() ? full.memoryReviews : 0,
connectors: alone() ? full.connectors : [],
capabilityStatuses: pick(full.capabilityStatuses),
// P-556: how many saves of this host replaced a linked `state.json`, with the generation of the
// process that counted them. This is a fact about the host, not about one member's data, so it
// reaches a signed-in deployment like an unsigned one: absent from this whitelist it reached
// nobody at all, and the notice was silently dead wherever anyone had signed in (Security, on
// review of #2473).
stateFileLinkReplaced: full.stateFileLinkReplaced ?? { gen: '', count: 0 },
}
}

Expand Down
4 changes: 4 additions & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ export function install(rt) {
memoryReviews: rt.memoryReviews(),
connectors: rt.connectorsView(),
capabilityStatuses: rt.capabilityStatusesView(),
// P-556: a save that replaced a linked `state.json` with a regular file keeps the team
// revision, so the count rides the live part of every answer — the `unchanged` one included —
// together with the generation of the host process that counted it (a restart starts over).
stateFileLinkReplaced: rt.stateLinkReplaced?.() ?? { gen: '', count: 0 },
}
// Plugin tool names change with a plugin reload, not with the team revision, so they ride every
// answer, unchanged or not, and the client re-reads its hidden-row set (#419).
Expand Down
27 changes: 26 additions & 1 deletion core/src/host/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// or group chat it belongs to.
import { randomUUID } from 'node:crypto'
import { constants, mkdirSync, statSync } from 'node:fs'
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { copyFile, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { LEGACY_THEMES, MAX_ENTRY, MAX_EXCHANGES, ROOM_MODES } from './constants.js'
import { homeLock } from './home-lock.js'
Expand Down Expand Up @@ -43,6 +43,16 @@ export function install(rt) {
// Every save this host has accepted since it started — a monotonic watermark, never decremented
// — and the chain's tail: what `savesQueued` and `savesSettled` answer (#1368).
let queued = 0
// Saves that replaced a linked `state.json` with a regular file (P-556): the host never writes
// through the link, so the file the person linked keeps every byte and stays where it is, and the
// path they opened is a file of this home's own from that save on. Counted once per save so a
// window can say what happened instead of leaving them to wonder.
//
// The count is answered with the generation of this process (Chief, on review): a restarted host
// starts counting at 0 again, and a window holding a larger count from the previous process would
// read `1 > 2` as old news and never say the save that came with the restart.
const linkGeneration = randomUUID()
let linkReplaced = 0
const { warn } = rt
// Opt-in (#511): every saved change is also recorded for replay; off by default.
// Opening the writer is itself a write — it creates `journal/` (0700) and repairs a half-written
Expand Down Expand Up @@ -920,6 +930,15 @@ export function install(rt) {
return true
}

// A name, not its target: `lstat` answers what the path is, and a save is the only place that
// turns a link into a regular file.
const isLink = async path => {
try {
return (await lstat(path)).isSymbolicLink()
} catch {
return false
}
}
const save = () => {
if (stopped) return writing
indexSessions()
Expand Down Expand Up @@ -957,7 +976,12 @@ export function install(rt) {
ensureLock()
await writeFile(temp, snapshot, 'utf8')
ensureLock()
// The rename replaces the name, link included, so a linked team file is never written
// through: its target keeps every byte and its last write time. Ask the name here, before
// the rename takes it, and count the save that did it.
const wasLink = await isLink(statePath)
await rename(temp, statePath)
if (wasLink) linkReplaced += 1
seen = stamp()
await journal?.record(snapshot)
rt.capabilitiesStateSaved?.()
Expand Down Expand Up @@ -1086,6 +1110,7 @@ export function install(rt) {

Object.assign(rt, {
state, load, save, refresh, readOnly, mutate, savesQueued, savesSettled, journal,
stateLinkReplaced: () => ({ gen: linkGeneration, count: linkReplaced }),
botOf, roomOf, isMain, chatOf, selfOf, isBotSession,
chatOwners, groupOwners, pastOwners,
groupSessionsOf, dropGroupSession, findBot, findRooms, isAdminOf,
Expand Down
99 changes: 99 additions & 0 deletions core/test/state-link-answer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// P-556: a save that replaces a linked `state.json` with a regular file does not move the team
// revision, so the count that tells a window about it has to ride every state answer — the
// `unchanged` one included. A window polling with `since` (the normal case) would otherwise never
// learn that its save left the linked file alone.
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { install as installAuthz } from '../src/host/authz.js'
import { install } from '../src/host/channel.js'

function channel(stateLinkReplaced, { signedIn = false } = {}) {
const routes = []
const ctx = {
effect: (execute) => {
execute()
return () => {}
},
connection: { fetch: { register: (route) => { routes.push(route); return () => {} } } },
logger: { warn() {} },
}
const rt = {
ctx,
// A deployment that has seen a sign-in is the case that matters here: authz then narrows the live
// part of the answer through its own `ownedLive` whitelist instead of passing everything on.
state: {
revision: 'r1',
bots: {},
rooms: {},
members: signedIn ? { m1: { id: 'm1', displayName: 'Me' } } : {},
authSessions: signedIn ? { token1: { memberId: 'm1' } } : {},
},
authenticate: async token => (signedIn && token === 'token1' ? { memberId: 'm1', displayName: 'Me' } : null),
warn: () => {},
noteFailure: () => {},
refresh: async () => {},
load: async () => {},
bootstrap: async () => {},
catalog: async () => {},
loadSecrets: async () => {},
readOnly: () => undefined,
authorize: () => ({ ok: true }),
activityView: () => ({}),
busyAcksView: () => ({}),
workView: () => ({}),
roomWorkView: () => ({}),
approvalsView: () => [],
endedView: () => [],
memoryNews: () => ({}),
memoryReviews: () => [],
connectorsView: () => [],
capabilityStatusesView: () => ({}),
ownedLive: (full) => full,
pluginToolNames: () => [],
stateLinkReplaced,
}
installAuthz(rt)
install(rt)
const call = async (endpoint, payload = {}) => (await routes[0].fetch(new Request('http://localhost/api/bot', {
method: 'POST',
headers: { 'content-type': 'application/json', ...(signedIn ? { 'x-flybot-member-session': 'token1' } : {}) },
body: JSON.stringify({ endpoint, payload }),
}))).json()
return { call, rt }
}

test('P-556: an unchanged state answer still carries the count and the generation it belongs to', async () => {
const { call } = channel(() => ({ gen: 'host-b', count: 2 }))
const value = (await call('state', { since: 'r1' })).value
assert.equal(value.unchanged, true, 'the same revision is answered as unchanged')
assert.deepEqual(value.stateFileLinkReplaced, { gen: 'host-b', count: 2 },
'the count rides the unchanged answer too, together with the process that made it')
})

test('P-556: a host that replaced no link answers 0 with its generation, never a missing field', async () => {
const { call } = channel(() => ({ gen: 'host-a', count: 0 }))
const value = (await call('state', { since: 'r1' })).value
assert.deepEqual(value.stateFileLinkReplaced, { gen: 'host-a', count: 0 })
})

// A host that never installed the counter (an older build answered the same endpoint) must still
// answer a shape a client can read: no field at all would be a `undefined` the notice has to guess at.
test('P-556: a host with no counter answers the empty generation and zero', async () => {
const { call } = channel(undefined)
const value = (await call('state', { since: 'r1' })).value
assert.deepEqual(value.stateFileLinkReplaced, { gen: '', count: 0 })
})

// Security, on review of the first tip: `channel.js` sends the live part through `rt.ownedLive(full,
// actor)`, and authz.js answers an explicit whitelist once anyone has signed in — a field it does not
// name never reaches the client, and the notice would silently never fire on any real deployment.
// This case drives that real whitelist: signed in, an actor resolved from the session header, and the
// count has to be in the answer with its generation.
test('P-556: a signed-in deployment still receives the count through the real ownedLive whitelist', async () => {
const { call, rt } = channel(() => ({ gen: 'host-a', count: 3 }), { signedIn: true })
assert.equal(rt.authzEnforced(), true, 'this case runs on a deployment where authz narrows the answer')
const value = (await call('state', { since: 'r1' })).value
assert.deepEqual(value.stateFileLinkReplaced, { gen: 'host-a', count: 3 },
'the count and its generation survive the authz whitelist')
assert.equal(value.unchanged, true, 'on the unchanged answer too, which is the one a poll gets')
})
Loading
Loading