diff --git a/apps/server/config.example.json b/apps/server/config.example.json index 201c86c..ef1d4e9 100644 --- a/apps/server/config.example.json +++ b/apps/server/config.example.json @@ -3,7 +3,7 @@ "team": { "name": "my-team", "directive": "Ship and operate the payment processing service.", - "brief": "We own the full lifecycle of the payment service: code, CI, deploys, and incident response. Our operating window is 0900-1700 UTC." + "context": "We own the full lifecycle of the payment service: code, CI, deploys, and incident response. Our operating window is 0900-1700 UTC." }, "roles": { "individual-contributor": { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 3be5734..d6edc13 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -76,7 +76,13 @@ import { composeBriefing } from './briefing.js'; import { type BusyTracker, createBusyTracker } from './busy-tracker.js'; import { type ChannelStore, ChannelsError, GENERAL_CHANNEL_ID, validateSlug } from './channels.js'; import { type EnrollmentStore, formatUserCode, normalizeUserCode } from './enrollments.js'; -import { type FilesystemStore, FsError, type ViewerContext } from './files/index.js'; +import { + basenameOf, + type FilesystemStore, + FsError, + objectiveNamespacePath, + type ViewerContext, +} from './files/index.js'; import type { JwtVerifier } from './jwt.js'; import type { Logger } from './logger.js'; import type { ActivityStore } from './member-activity.js'; @@ -1264,20 +1270,45 @@ export function createApp(options: AppOptions): CreatedApp { assignee: created.assignee, attachments: created.attachments.length, }); - // Grant every initial thread member access to the attachments. - // `objectiveThreadMembers` already knows the originator, - // assignee, explicit watchers, and all admins — so one - // call covers everyone who should see these files. + + // Mirror each attachment into the objective's own namespace + // (`/objectives//...`) so the file's home is the + // objective, not whichever member uploaded it. The originator's + // home copy stays put — `copyByBlobRef` shares a single + // underlying blob, so bytes aren't duplicated, but each entry + // is independently deletable. + // + // No fallback: if any copy fails, the whole create surfaces + // the error. A half-mirrored objective with mixed + // namespace/pointer paths is harder to reason about than a + // clean retry, and there's no legacy data to coexist with. + let finalObjective = created; if (files && created.attachments.length > 0) { - const members = objectiveThreadMembers(created); - grantAttachmentsTo(files, created.attachments, members, `obj:${created.id}`, logger); + const viewer = toViewer(member); + const namespacePaths: Attachment[] = created.attachments.map((att) => { + const dst = objectiveNamespacePath(created.id, basenameOf(att.path)); + const copied = files.copyByBlobRef({ + src: att.path, + dst, + mimeType: att.mimeType, + collision: 'suffix', + viewer, + }); + return { + path: copied.path, + name: copied.name, + size: copied.size ?? att.size, + mimeType: copied.mimeType ?? att.mimeType, + }; + }); + finalObjective = objectives.setAttachments(created.id, namespacePaths); } queueMicrotask(() => { for (const ev of events) { - void publishObjectiveEvent(created, ev, member.name); + void publishObjectiveEvent(finalObjective, ev, member.name); } }); - return c.json(created); + return c.json(finalObjective); } catch (err) { const mapped = mapObjectivesError(err); return c.json(mapped.body, mapped.status as 400 | 404 | 409 | 500); @@ -1400,18 +1431,10 @@ export function createApp(options: AppOptions): CreatedApp { } try { const { objective: updated, events } = objectives.reassign(id, parsed.data, member.name); - // Backfill attachment grants for the new assignee — they're - // now a thread member and should be able to download - // anything that was attached to the objective at creation. - if (files && updated.attachments.length > 0) { - grantAttachmentsTo( - files, - updated.attachments, - [updated.assignee], - `obj:${updated.id}`, - logger, - ); - } + // Attachment access for the new assignee comes "for free" from + // the objective-namespace ACL — they're now a thread member, + // so `canRead('/objectives//...')` returns true via + // `isObjectiveMember`. No grant backfill needed. queueMicrotask(() => { for (const ev of events) { void publishObjectiveEvent(updated, ev, member.name); @@ -1473,21 +1496,11 @@ export function createApp(options: AppOptions): CreatedApp { parsed.data, member.name, ); - // Every watcher_added event carries a name; backfill attachment - // grants for each newly-added watcher so they can read files - // that were attached to the objective before they joined the - // thread. - if (files && updated.attachments.length > 0) { - const addedNames: string[] = []; - for (const ev of events) { - if (ev.kind === 'watcher_added' && typeof ev.payload.name === 'string') { - addedNames.push(ev.payload.name); - } - } - if (addedNames.length > 0) { - grantAttachmentsTo(files, updated.attachments, addedNames, `obj:${updated.id}`, logger); - } - } + // Watcher membership changes have no FS-side bookkeeping to do: + // attachment access flows from `isObjectiveMember` in the + // namespace ACL, so adding a watcher grants access at the + // moment the membership lands and removing one revokes it the + // moment they're gone. No grant rows to backfill or sweep. queueMicrotask(() => { for (const ev of events) { void publishObjectiveEvent(updated, ev, member.name); @@ -1990,7 +2003,7 @@ export function createApp(options: AppOptions): CreatedApp { // Read is dual-auth (every authenticated member sees their team). // Mutations require `team.manage`. The response always reflects the // freshly-read DB state — there is no in-memory snapshot to go - // stale. Note: changing `directive` / `brief` / member `instructions` + // stale. Note: changing `directive` / `context` / member `instructions` // takes effect on the *next* MCP session for any agent — those // strings are baked into the MCP `instructions` field, which is // frozen for the lifetime of a session by the protocol. Restart the @@ -2007,12 +2020,12 @@ export function createApp(options: AppOptions): CreatedApp { } const body = (await c.req.json().catch(() => null)) as Record | null; if (body === null) return c.json({ error: 'expected a JSON body' }, 400); - const patch: { name?: string; directive?: string; brief?: string } = {}; + const patch: { name?: string; directive?: string; context?: string } = {}; if (typeof body.name === 'string') patch.name = body.name; if (typeof body.directive === 'string') patch.directive = body.directive; - if (typeof body.brief === 'string') patch.brief = body.brief; + if (typeof body.context === 'string') patch.context = body.context; if (Object.keys(patch).length === 0) { - return c.json({ error: 'no fields to update (name, directive, brief)' }, 400); + return c.json({ error: 'no fields to update (name, directive, context)' }, 400); } try { const updated = teamStore.updateTeam(patch, member.name); @@ -2832,6 +2845,19 @@ export function createApp(options: AppOptions): CreatedApp { return c.json({ entries }); }); + // `/fs/all` — admin-only flat enumeration of every file in every + // home, newest-first. Non-admins use the per-home tree under + // `//...` for their own files and `/fs/shared` for the + // grants other members have given them. + app.get(PATHS.fsAll, auth, (c) => { + try { + const entries = fsStore.listAllFiles(toViewer(c.get('member'))); + return c.json({ entries }); + } catch (err) { + return mapFsError(c, err); + } + }); + // `/fs/read/*` — catch-all, single URL-decoded segment per path // component so `` just // works. The `*` route lives in its own handler so Hono's diff --git a/apps/server/src/briefing.ts b/apps/server/src/briefing.ts index a1ab774..0698c4a 100644 --- a/apps/server/src/briefing.ts +++ b/apps/server/src/briefing.ts @@ -82,7 +82,7 @@ function composePrompt(self: Member, team: Team, others: Teammate[]): string { ``, `Team: ${team.name}`, `Directive: ${team.directive}`, - team.brief.trim().length > 0 && `Brief: ${team.brief}`, + team.context.trim().length > 0 && `Context: ${team.context}`, ``, selfInstructions.length > 0 && `Personal instructions:`, selfInstructions.length > 0 && selfInstructions, diff --git a/apps/server/src/files/filesystem-store.ts b/apps/server/src/files/filesystem-store.ts index b00f180..3c75882 100644 --- a/apps/server/src/files/filesystem-store.ts +++ b/apps/server/src/files/filesystem-store.ts @@ -40,6 +40,7 @@ import { normalizePath, ownerOf, parentOf, + parseObjectiveNamespacePath, ROOT_PATH, } from './paths.js'; @@ -131,10 +132,42 @@ export interface WriteFileResult { renamed: boolean; } +/** + * Hook for the objective-namespace ACL gate. Decoupled from the + * objectives store so the FS layer doesn't have to know what an + * objective row looks like — it only needs the membership question + * answered. + * + * `isMember` returns true if `viewerName` is the originator, the + * current assignee, or one of the watchers of `objectiveId`. The FS + * store calls this whenever a path under `/objectives//...` is + * read, written, or deleted by a non-admin viewer. + * + * Optional at construction time — without a provider, the namespace + * is silently treated as forbidden for non-admins (read/write 403), + * which is the right failure mode while the caller is wiring things up. + */ +export interface ObjectiveAclProvider { + isMember(objectiveId: string, viewerName: string): boolean; +} + export interface FilesystemStore { stat(path: string, viewer: ViewerContext): FsEntry | null; list(path: string, viewer: ViewerContext): FsEntry[]; listShared(viewer: ViewerContext): FsEntry[]; + /** Admin-only flat enumeration of every file across the team. */ + listAllFiles(viewer: ViewerContext): FsEntry[]; + + /** + * Create a new file entry that points at the same blob as `srcPath`. + * Refcount-aware — the blob bytes are not duplicated; only the + * metadata row is. Used to materialize objective attachments into + * `/objectives//...` while keeping the originator's home copy + * intact. Source must be readable by `viewer`; destination must be + * writable by `viewer`. Honors the same collision strategies as + * `writeFile`. + */ + copyByBlobRef(input: CopyByBlobRefInput): FsEntry; openReadStream(path: string, viewer: ViewerContext): { entry: FsEntry; stream: Readable }; @@ -160,14 +193,31 @@ export interface FilesystemStore { ensureHome(slotName: string, now?: number): void; } +export interface CopyByBlobRefInput { + src: string; + dst: string; + mimeType?: string; + /** Default 'error'. Same semantics as `writeFile`. */ + collision?: WriteCollisionStrategy; + viewer: ViewerContext; +} + interface SqliteFilesystemStoreOptions { db: DatabaseSyncInstance; blobs: BlobStore; + /** + * ACL provider for the `/objectives//...` namespace. Injected + * rather than imported so the FS package has no inbound dependency + * on the objectives store. Optional — without it, namespace paths + * 403 for non-admins. + */ + objectiveAcl?: ObjectiveAclProvider; } class SqliteFilesystemStore implements FilesystemStore { private readonly db: DatabaseSyncInstance; private readonly blobs: BlobStore; + private readonly objectiveAcl: ObjectiveAclProvider | null; private readonly getEntryStmt: StatementInstance; private readonly listChildrenStmt: StatementInstance; @@ -185,10 +235,12 @@ class SqliteFilesystemStore implements FilesystemStore { private readonly hasGrantStmt: StatementInstance; private readonly movePathStmt: StatementInstance; private readonly listHomesStmt: StatementInstance; + private readonly listAllFilesStmt: StatementInstance; constructor(opts: SqliteFilesystemStoreOptions) { this.db = opts.db; this.blobs = opts.blobs; + this.objectiveAcl = opts.objectiveAcl ?? null; this.db.exec(CREATE_SCHEMA); this.getEntryStmt = this.db.prepare('SELECT * FROM fs_entries WHERE path = ?'); @@ -237,6 +289,9 @@ class SqliteFilesystemStore implements FilesystemStore { this.listHomesStmt = this.db.prepare( "SELECT * FROM fs_entries WHERE parent_path = '/' AND kind = 'directory' ORDER BY name", ); + this.listAllFilesStmt = this.db.prepare( + "SELECT * FROM fs_entries WHERE kind = 'file' ORDER BY updated_at DESC", + ); } // ─── permissions ──────────────────────────────────────────────── @@ -246,15 +301,30 @@ class SqliteFilesystemStore implements FilesystemStore { return ownerOf(path) === viewer.name; } + /** + * Membership in the objective whose namespace contains `path`. Returns + * `false` for paths outside the objective namespace and for the bare + * `/objectives` parent (which has no specific objective). Used as the + * ACL gate for both read and write under `/objectives//...`. + */ + private isObjectiveMember(path: string, viewer: ViewerContext): boolean { + if (this.objectiveAcl === null) return false; + const parsed = parseObjectiveNamespacePath(path); + if (parsed === null) return false; + return this.objectiveAcl.isMember(parsed.id, viewer.name); + } + private canRead(path: string, viewer: ViewerContext): boolean { if (viewer.permissions.includes('members.manage')) return true; if (this.ownsPath(path, viewer)) return true; + if (this.isObjectiveMember(path, viewer)) return true; return this.hasGrant(path, viewer.name); } private canWrite(path: string, viewer: ViewerContext): boolean { if (viewer.permissions.includes('members.manage')) return true; - return this.ownsPath(path, viewer); + if (this.ownsPath(path, viewer)) return true; + return this.isObjectiveMember(path, viewer); } // ─── read API ────────────────────────────────────────────────── @@ -306,6 +376,20 @@ class SqliteFilesystemStore implements FilesystemStore { return rows.map(rowToEntry); } + /** + * Flat list of every file across every home, newest first. Admin-only — + * the existing tree navigation under `//` is the right path + * for non-directors, who shouldn't see the global file list. Throws + * `forbidden` if the caller lacks `members.manage`. + */ + listAllFiles(viewer: ViewerContext): FsEntry[] { + if (!viewer.permissions.includes('members.manage')) { + throw new FsError('forbidden', 'admin permission required to list all files'); + } + const rows = this.listAllFilesStmt.all() as unknown as FsEntryRow[]; + return rows.map(rowToEntry); + } + openReadStream(path: string, viewer: ViewerContext): { entry: FsEntry; stream: Readable } { const entry = this.stat(path, viewer); if (!entry) throw new FsError('not_found', `no such path: ${path}`); @@ -408,6 +492,102 @@ class SqliteFilesystemStore implements FilesystemStore { return { entry: rowToEntry(row), renamed }; } + /** + * Materialize a new entry at `dst` that shares the underlying blob + * with `src`. Used to mount objective attachments into + * `/objectives//...` without duplicating blob bytes. + * + * Refcount semantics: the shared blob's refcount is incremented by + * one — the source entry still holds its own reference, so deleting + * either entry leaves the other intact, and the bytes are dropped + * from disk only when the last entry referencing them goes away. + */ + copyByBlobRef(input: CopyByBlobRefInput): FsEntry { + const src = normalizePath(input.src); + const dstNormalized = normalizePath(input.dst); + if (dstNormalized === ROOT_PATH) { + throw new FsError('invalid_input', 'cannot copy to root'); + } + if (!this.canRead(src, input.viewer)) { + throw new FsError('forbidden', `cannot read source: ${src}`); + } + if (!this.canWrite(dstNormalized, input.viewer)) { + throw new FsError('forbidden', `cannot write to destination: ${dstNormalized}`); + } + const srcRow = this.getEntryStmt.get(src) as FsEntryRow | undefined; + if (!srcRow) throw new FsError('not_found', `no such source: ${src}`); + if (srcRow.kind !== 'file') { + throw new FsError('is_a_directory', `source is a directory: ${src}`); + } + if (!srcRow.content_hash || srcRow.size === null) { + throw new FsError('corrupt', `source has no content: ${src}`); + } + const mimeType = input.mimeType ?? srcRow.mime_type ?? 'application/octet-stream'; + const now = Date.now(); + + // Auto-create dst ancestors (e.g. `/objectives` and + // `/objectives/`) before the metadata transaction. Same + // pattern as `writeFile` — keeps mkdir its own idempotent step. + this.ensureDirectoryTree(parentOf(dstNormalized), input.viewer, now); + + const collision = input.collision ?? 'error'; + let finalPath = dstNormalized; + + this.withTx(() => { + let existing = this.getEntryStmt.get(finalPath) as FsEntryRow | undefined; + if (existing) { + if (existing.kind === 'directory') { + throw new FsError('is_a_directory', `cannot overwrite directory: ${finalPath}`); + } + if (collision === 'error') { + throw new FsError('exists', `already exists: ${finalPath}`); + } + if (collision === 'suffix') { + const parentPath = existing.parent_path; + const base = basenameOf(finalPath); + const newName = dedupeBasename(base, (candidate) => { + const p = joinPath(parentPath, candidate); + return this.getEntryStmt.get(p) !== undefined; + }); + finalPath = joinPath(parentPath, newName); + existing = undefined; + } + } + + if (existing && collision === 'overwrite') { + const priorHash = existing.content_hash; + this.updateEntryContentStmt.run(srcRow.content_hash, srcRow.size, mimeType, now, finalPath); + this.incRefStmt.run(srcRow.content_hash); + if (priorHash && priorHash !== srcRow.content_hash) { + this.decrementAndMaybeDropBlob(priorHash); + } + } else { + const ownerName = ownerOf(finalPath); + if (!ownerName) { + throw new FsError('invalid_input', 'cannot write directly under root'); + } + this.insertEntryStmt.run( + finalPath, + parentOf(finalPath), + basenameOf(finalPath), + 'file', + ownerName, + srcRow.content_hash, + srcRow.size, + mimeType, + now, + input.viewer.name, + now, + ); + this.incRefStmt.run(srcRow.content_hash); + } + }); + + const row = this.getEntryStmt.get(finalPath) as FsEntryRow | undefined; + if (!row) throw new FsError('corrupt', `entry vanished after copy: ${finalPath}`); + return rowToEntry(row); + } + mkdir(path: string, writer: ViewerContext, opts: { recursive?: boolean } = {}): FsEntry { const normalized = normalizePath(path); if (normalized === ROOT_PATH) { diff --git a/apps/server/src/files/index.ts b/apps/server/src/files/index.ts index 1494f33..66c16ab 100644 --- a/apps/server/src/files/index.ts +++ b/apps/server/src/files/index.ts @@ -1,8 +1,10 @@ export { type BlobStore, LocalBlobStore, type PutOptions, type PutResult } from './blob-store.js'; export { FsError, type FsErrorCode } from './errors.js'; export { + type CopyByBlobRefInput, createSqliteFilesystemStore, type FilesystemStore, + type ObjectiveAclProvider, type ViewerContext, type WriteCollisionStrategy, type WriteFileInput, @@ -16,8 +18,12 @@ export { MAX_PATH_LENGTH, MAX_SEGMENT_LENGTH, normalizePath, + OBJECTIVE_NAMESPACE_SEGMENT, + OBJECTIVE_OWNER_PREFIX, + objectiveNamespacePath, ownerOf, parentOf, + parseObjectiveNamespacePath, ROOT_PATH, splitPath, } from './paths.js'; diff --git a/apps/server/src/files/paths.ts b/apps/server/src/files/paths.ts index 28f3b42..3c2c0d2 100644 --- a/apps/server/src/files/paths.ts +++ b/apps/server/src/files/paths.ts @@ -1,14 +1,23 @@ /** * Path utilities for the ac7 filesystem. * - * Paths are absolute, Unix-like, with `/` as separator. The first - * segment is the owner (a slot name). Segments may contain - * alphanumerics, dot, underscore, hyphen, and single spaces; no - * `.`/`..` traversal; no leading/trailing whitespace; no empty + * Paths are absolute, Unix-like, with `/` as separator. Segments may + * contain alphanumerics, dot, underscore, hyphen, and single spaces; + * no `.`/`..` traversal; no leading/trailing whitespace; no empty * segments between slashes. * + * Two top-level scopes share the path tree: + * - **Member homes** at `//` — the original per-slot + * scope. The first path segment is the member name; that name is + * also the row's `owner` value, and the read/write ACL is + * "owner-only + admin + grant-holders". + * - **Objective namespaces** at `/objectives//` — a per-objective + * scope owned not by an individual but by the objective's member + * set (originator + assignee + watchers). Rows under this prefix + * have `owner = 'obj:'`; the ACL gate is "member of the + * objective + admin". + * * The root `/` has no owner and is implicit (no DB row represents it). - * Every slot's home is `//`. */ import { FsError } from './errors.js'; @@ -18,6 +27,15 @@ export const ROOT_PATH = '/' as const; export const MAX_PATH_LENGTH = 1024; export const MAX_SEGMENT_LENGTH = 255; +/** + * Top-level segment for the objective-scope namespace. We use the + * spelled-out word `objectives` in paths to keep them readable, while + * the `owner` column carries the abbreviated `obj:` form so it + * matches the `obj:` thread-key prefix used elsewhere. + */ +export const OBJECTIVE_NAMESPACE_SEGMENT = 'objectives'; +export const OBJECTIVE_OWNER_PREFIX = 'obj:'; + const SEGMENT_RE = /^[a-zA-Z0-9._\- ]+$/; /** @@ -84,14 +102,52 @@ export function basenameOf(path: string): string { } /** - * First segment — the slot that owns this subtree. Root → null (no owner). + * Authoritative `owner` column value for the row at `path`. + * + * `/alice/...` → `'alice'` (member-home scope) + * `/objectives/foo/...` → `'obj:foo'` (objective scope) + * `/objectives` → `'objectives'` (the bare namespace dir; + * no individual owns it) + * `/` → `null` (root; no DB row) + * + * Centralizing this here means write-time row creation and ACL checks + * agree on the same scope tag without each call site re-deriving it. */ export function ownerOf(path: string): string | null { const segments = splitPath(path); if (segments.length === 0) return null; + if (segments[0] === OBJECTIVE_NAMESPACE_SEGMENT) { + if (segments.length === 1) return OBJECTIVE_NAMESPACE_SEGMENT; + return `${OBJECTIVE_OWNER_PREFIX}${segments[1]}`; + } return segments[0] as string; } +/** + * If `path` lives in the objective namespace, return its `id` and the + * subpath under that objective's root. Returns `null` for any other + * path (member homes, root, the bare `/objectives` parent). + * + * `/objectives/foo/spec.pdf` → `{ id: 'foo', subpath: 'spec.pdf' }` + * `/objectives/foo` → `{ id: 'foo', subpath: '' }` + * `/objectives` → `null` (the bare namespace dir + * belongs to no specific objective) + * `/alice/...` → `null` + */ +export function parseObjectiveNamespacePath(path: string): { id: string; subpath: string } | null { + const segments = splitPath(path); + if (segments.length < 2) return null; + if (segments[0] !== OBJECTIVE_NAMESPACE_SEGMENT) return null; + const id = segments[1] as string; + const subpath = segments.slice(2).join('/'); + return { id, subpath }; +} + +/** Build a normalized path under an objective's namespace. */ +export function objectiveNamespacePath(id: string, ...subpath: string[]): string { + return joinPath(`/${OBJECTIVE_NAMESPACE_SEGMENT}`, id, ...subpath); +} + /** * Join parts into a single normalized path. Empty segments are * dropped. Useful for composing a home + subpath without worrying diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 6e497c1..6230469 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -274,7 +274,7 @@ async function main(): Promise { stores.team.setTeam({ name: wizard.team.name, directive: wizard.team.directive, - brief: wizard.team.brief, + context: wizard.team.context, }); for (const [name, leaves] of Object.entries(wizard.team.permissionPresets)) { stores.team.setPreset(name, leaves); diff --git a/apps/server/src/members.ts b/apps/server/src/members.ts index f557d52..c247bd4 100644 --- a/apps/server/src/members.ts +++ b/apps/server/src/members.ts @@ -21,7 +21,7 @@ * "team": { * "name": "demo-team", * "directive": "Ship the payment service.", - * "brief": "We own the full lifecycle...", + * "context": "We own the full lifecycle...", * "permissionPresets": { * "admin": ["team.manage", "members.manage", "objectives.create", "objectives.cancel", "objectives.reassign", "objectives.watch", "activity.read"], * "operator": ["objectives.create", "objectives.cancel", "objectives.reassign"] @@ -112,7 +112,7 @@ const PermissionLeafSchema = z.enum(PERMISSIONS); const TeamNameSchema = z.string().min(1).max(128); const TeamDirectiveSchema = z.string().min(1).max(512); -const TeamBriefSchema = z.string().max(4096).default(''); +const TeamContextSchema = z.string().max(4096).default(''); const MemberNameSchema = z .string() .min(1) @@ -220,11 +220,11 @@ export function validateTeamDirective(directive: string): void { failFromZod('team.directive', err); } } -export function validateTeamBrief(brief: string): void { +export function validateTeamContext(context: string): void { try { - TeamBriefSchema.parse(brief); + TeamContextSchema.parse(context); } catch (err) { - failFromZod('team.brief', err); + failFromZod('team.context', err); } } export function validateMemberName(name: string): void { diff --git a/apps/server/src/objectives.ts b/apps/server/src/objectives.ts index 2303942..afea057 100644 --- a/apps/server/src/objectives.ts +++ b/apps/server/src/objectives.ts @@ -256,6 +256,14 @@ export interface ObjectivesStore { actor: string, now?: number, ): ObjectivesMutationResult; + /** + * Replace the `attachments` JSON column without producing an audit + * event. Used by the server route after `create` to swap the + * originally-claimed attachment paths (in member homes) for their + * mirrored copies in the `/objectives//...` namespace. Returns + * the updated objective. + */ + setAttachments(id: string, attachments: Attachment[], now?: number): Objective; } class SqliteObjectivesStore implements ObjectivesStore { @@ -268,6 +276,7 @@ class SqliteObjectivesStore implements ObjectivesStore { private readonly insertStmt: StatementInstance; private readonly updateRowStmt: StatementInstance; private readonly updateWatchersStmt: StatementInstance; + private readonly updateAttachmentsStmt: StatementInstance; private readonly insertEventStmt: StatementInstance; private readonly listEventsStmt: StatementInstance; @@ -315,6 +324,9 @@ class SqliteObjectivesStore implements ObjectivesStore { this.updateWatchersStmt = db.prepare( 'UPDATE objectives SET watchers = ?, updated_at = ? WHERE id = ?', ); + this.updateAttachmentsStmt = db.prepare( + 'UPDATE objectives SET attachments = ?, updated_at = ? WHERE id = ?', + ); this.insertEventStmt = db.prepare( 'INSERT INTO objective_events (objective_id, ts, actor, kind, payload) VALUES (?, ?, ?, ?, ?)', ); @@ -703,6 +715,15 @@ class SqliteObjectivesStore implements ObjectivesStore { return { objective: updated, events }; } + setAttachments(id: string, attachments: Attachment[], now = Date.now()): Objective { + const current = this.get(id); + if (!current) throw new ObjectivesError('not_found', `objective ${id} not found`); + this.updateAttachmentsStmt.run(JSON.stringify(attachments), now, id); + const updated = this.get(id); + if (!updated) throw new ObjectivesError('not_found', `objective ${id} vanished after update`); + return updated; + } + private appendEvent( id: string, ts: number, diff --git a/apps/server/src/run.ts b/apps/server/src/run.ts index 867f7cf..3d172ea 100644 --- a/apps/server/src/run.ts +++ b/apps/server/src/run.ts @@ -363,7 +363,24 @@ export async function runServer(options: RunServerOptions): Promise { +): Promise<{ name: string; directive: string; context: string }> { const name = await promptRequired(io, 'team name [my-team]: ', 'my-team', (v) => v.length > 0 && v.length <= 128 ? null : 'must be 1-128 characters', ); @@ -179,8 +179,10 @@ async function promptTeam( '', (v) => (v.length > 0 && v.length <= 512 ? null : 'directive is required, max 512 chars'), ); - const brief = (await io.prompt('brief (longer context, press enter to skip): ')).trim(); - return { name, directive, brief }; + const context = ( + await io.prompt('team context (longer background, press enter to skip): ') + ).trim(); + return { name, directive, context }; } async function promptRequired( diff --git a/apps/server/test/agent-activity.test.ts b/apps/server/test/agent-activity.test.ts index 43be03b..8fda0cb 100644 --- a/apps/server/test/agent-activity.test.ts +++ b/apps/server/test/agent-activity.test.ts @@ -40,7 +40,7 @@ const OTHER_TOKEN = 'ac7_test_other'; const TEAM: Team = { name: 'demo-team', directive: 'Ship the payments service.', - brief: 'End-to-end ownership.', + context: 'End-to-end ownership.', permissionPresets: {}, }; diff --git a/apps/server/test/app.test.ts b/apps/server/test/app.test.ts index cc6fe26..cda2e4a 100644 --- a/apps/server/test/app.test.ts +++ b/apps/server/test/app.test.ts @@ -15,7 +15,7 @@ const BOT_TOKEN = 'ac7_test_bot_secret'; const TEAM: Team = { name: 'demo-team', directive: 'Ship and operate the payment service.', - brief: 'We own the full lifecycle.', + context: 'We own the full lifecycle.', permissionPresets: {}, }; diff --git a/apps/server/test/auth.test.ts b/apps/server/test/auth.test.ts index c0e6fc0..bb69672 100644 --- a/apps/server/test/auth.test.ts +++ b/apps/server/test/auth.test.ts @@ -28,7 +28,7 @@ const BOT_TOKEN = 'ac7_auth_test_bot_token'; const TEAM: Team = { name: 'demo-team', directive: 'Verify the auth surface.', - brief: '', + context: '', permissionPresets: {}, }; diff --git a/apps/server/test/briefing.test.ts b/apps/server/test/briefing.test.ts index 0725a25..a403d00 100644 --- a/apps/server/test/briefing.test.ts +++ b/apps/server/test/briefing.test.ts @@ -5,7 +5,7 @@ import { composeBriefing } from '../src/briefing.js'; const TEAM: Team = { name: 'demo-team', directive: 'Ship the payment service.', - brief: 'We own the full lifecycle of the payment service.', + context: 'We own the full lifecycle of the payment service.', permissionPresets: {}, }; @@ -73,7 +73,7 @@ describe('composeBriefing', () => { expect(briefing.instructions).toContain('Your role here: engineer'); expect(briefing.instructions).toContain(TEAM.name); expect(briefing.instructions).toContain(TEAM.directive); - expect(briefing.instructions).toContain(TEAM.brief); + expect(briefing.instructions).toContain(TEAM.context); expect(briefing.instructions).toContain(ALPHA_1.instructions); }); @@ -94,16 +94,16 @@ describe('composeBriefing', () => { expect(linesAfterHeader).not.toMatch(/^\s{2}engineer-1\s/m); }); - it('omits the brief line when team.brief is empty', () => { - const teamNoBrief: Team = { ...TEAM, brief: '' }; + it('omits the context line when team.context is empty', () => { + const teamNoContext: Team = { ...TEAM, context: '' }; const briefing = composeBriefing({ self: DIRECTOR, - team: teamNoBrief, + team: teamNoContext, teammates: TEAMMATES, openObjectives: [], }); - expect(briefing.instructions).not.toContain('Brief:'); - expect(briefing.instructions).toContain(`Directive: ${teamNoBrief.directive}`); + expect(briefing.instructions).not.toContain('Context:'); + expect(briefing.instructions).toContain(`Directive: ${teamNoContext.directive}`); }); it('omits the personal-instructions block when the member has none', () => { diff --git a/apps/server/test/channels-endpoints.test.ts b/apps/server/test/channels-endpoints.test.ts index e20771e..7edb1ff 100644 --- a/apps/server/test/channels-endpoints.test.ts +++ b/apps/server/test/channels-endpoints.test.ts @@ -16,7 +16,7 @@ const CAROL = 'ac7_test_carol_secret'; const TEAM: Team = { name: 'demo-team', directive: 'Ship the thing.', - brief: '', + context: '', permissionPresets: {}, }; diff --git a/apps/server/test/e2e.test.ts b/apps/server/test/e2e.test.ts index fb2fdd8..f98ae04 100644 --- a/apps/server/test/e2e.test.ts +++ b/apps/server/test/e2e.test.ts @@ -50,7 +50,7 @@ const PEER_TOKEN = 'ac7_test_peer'; const TEAM: Team = { name: 'e2e-team', directive: 'Exercise the full ac7 stack end-to-end.', - brief: '', + context: '', permissionPresets: {}, }; diff --git a/apps/server/test/enroll-endpoints.test.ts b/apps/server/test/enroll-endpoints.test.ts index 0850dd1..a912835 100644 --- a/apps/server/test/enroll-endpoints.test.ts +++ b/apps/server/test/enroll-endpoints.test.ts @@ -37,7 +37,7 @@ const NON_ADMIN_TOKEN = 'ac7_enroll_engineer_token'; const TEAM: Team = { name: 'enroll-team', directive: 'Exercise the device-code flow.', - brief: '', + context: '', permissionPresets: { admin: [ 'team.manage', diff --git a/apps/server/test/files/fs-routes.test.ts b/apps/server/test/files/fs-routes.test.ts index 8646c76..dec30e2 100644 --- a/apps/server/test/files/fs-routes.test.ts +++ b/apps/server/test/files/fs-routes.test.ts @@ -28,7 +28,7 @@ const DIRECTOR_TOKEN = 'ac7_test_director_secret'; const TEAM: Team = { name: 'files-team', directive: 'Exercise filesystem endpoints.', - brief: '', + context: '', permissionPresets: {}, }; diff --git a/apps/server/test/files/objective-namespace.test.ts b/apps/server/test/files/objective-namespace.test.ts new file mode 100644 index 0000000..fd97155 --- /dev/null +++ b/apps/server/test/files/objective-namespace.test.ts @@ -0,0 +1,290 @@ +/** + * End-to-end tests for the `/objectives//...` filesystem + * namespace. + * + * Wires the FS store + objectives store + Hono routes through a real + * in-memory SQLite + temp blob root so attachment mirroring, + * membership-based ACL, and watcher-removal grant cleanup all + * exercise together. + * + * What we're trying to prove here: + * 1. Creating an objective with attachments mirrors the file into + * `/objectives//` and updates the objective's + * attachments to point at the namespace path. The originator's + * home copy stays put (so deletes from the home don't break the + * objective). + * 2. Members of the objective (originator, assignee, watchers) can + * read AND write under the namespace. Non-members 403. + * 3. Removing a watcher revokes their `obj:` grants on legacy + * pointer attachments — the (b) bug fix. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Broker, InMemoryEventLog } from '@agentc7/core'; +import type { Objective, Team } from '@agentc7/sdk/types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createApp } from '../../src/app.js'; +import { openDatabase } from '../../src/db.js'; +import { createSqliteFilesystemStore, LocalBlobStore } from '../../src/files/index.js'; +import { createMemberStore } from '../../src/members.js'; +import { createSqliteObjectivesStore } from '../../src/objectives.js'; +import { SessionStore } from '../../src/sessions.js'; +import { createTokenStoreFromMembers } from '../../src/tokens.js'; +import { mockTeamStore } from '../helpers/test-stores.js'; + +const ALICE = 'ac7_test_alice_secret'; +const BOB = 'ac7_test_bob_secret'; +const CAROL = 'ac7_test_carol_secret'; +const DAVE = 'ac7_test_dave_secret'; + +const TEAM: Team = { + name: 'obj-fs-team', + directive: 'Files live with their objective.', + context: '', + permissionPresets: {}, +}; + +const tmpDirs: string[] = []; + +afterEach(() => { + for (const d of tmpDirs.splice(0)) { + rmSync(d, { recursive: true, force: true }); + } +}); + +function makeApp() { + const broker = new Broker({ + eventLog: new InMemoryEventLog(), + now: () => 1_700_000_000_000, + idFactory: (() => { + let n = 0; + return () => `msg-${++n}`; + })(), + }); + const members = createMemberStore([ + { + name: 'alice', + role: { title: 'admin', description: '' }, + permissions: ['members.manage', 'objectives.create', 'objectives.watch'], + token: ALICE, + }, + { + name: 'bob', + role: { title: 'engineer', description: '' }, + permissions: ['objectives.create'], + token: BOB, + }, + { + name: 'carol', + role: { title: 'engineer', description: '' }, + permissions: [], + token: CAROL, + }, + { + name: 'dave', + role: { title: 'engineer', description: '' }, + permissions: [], + token: DAVE, + }, + ]); + broker.seedMembers(members.members()); + const db = openDatabase(':memory:'); + const sessions = new SessionStore(db); + const tokens = createTokenStoreFromMembers(db, members); + const blobDir = mkdtempSync(join(tmpdir(), 'ac7-objfs-')); + tmpDirs.push(blobDir); + const blobs = new LocalBlobStore(blobDir); + const objectives = createSqliteObjectivesStore(db); + const files = createSqliteFilesystemStore({ + db, + blobs, + objectiveAcl: { + isMember(objectiveId, viewerName) { + const obj = objectives.get(objectiveId); + if (obj === null) return false; + if (obj.originator === viewerName) return true; + if (obj.assignee === viewerName) return true; + return obj.watchers.includes(viewerName); + }, + }, + }); + for (const m of members.members()) { + files.ensureHome(m.name); + } + const { app } = createApp({ + broker, + members, + tokens, + sessions, + teamStore: mockTeamStore(TEAM), + objectives, + files, + version: '0.0.0', + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + return { app, files, objectives }; +} + +function authed(token: string, body?: unknown, method?: string): RequestInit { + const init: RequestInit = { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }; + init.method = method ?? (body !== undefined ? 'POST' : 'GET'); + if (body !== undefined) init.body = JSON.stringify(body); + return init; +} + +async function uploadToHome( + app: ReturnType['app'], + token: string, + path: string, + body: string, +): Promise { + const res = await app.request( + `/fs/write?path=${encodeURIComponent(path)}&mime=${encodeURIComponent('text/plain')}`, + { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body, + }, + ); + expect(res.status).toBe(200); +} + +describe('/objectives// namespace', () => { + it('mirrors create-time attachments into the namespace and points the objective there', async () => { + const { app } = makeApp(); + // Bob uploads a spec to his home, then creates an objective with it. + await uploadToHome(app, BOB, '/bob/specs/payment.md', '# Payment service\n'); + const res = await app.request( + '/objectives', + authed(BOB, { + title: 'Ship payment service', + outcome: 'PR merged', + body: '', + assignee: 'carol', + attachments: [ + { path: '/bob/specs/payment.md', name: 'payment.md', size: 1, mimeType: 'text/plain' }, + ], + }), + ); + expect(res.status).toBe(200); + const obj = (await res.json()) as Objective; + expect(obj.attachments).toHaveLength(1); + // The objective's attachment now lives in the namespace. + expect(obj.attachments[0]?.path).toBe(`/objectives/${obj.id}/payment.md`); + // Bob's home copy is untouched — the original is still readable from there. + const fromHome = await app.request( + `/fs/stat?path=${encodeURIComponent('/bob/specs/payment.md')}`, + authed(BOB), + ); + expect(fromHome.status).toBe(200); + }); + + it('lets every objective member read AND write under the namespace, and 403s non-members', async () => { + const { app } = makeApp(); + await uploadToHome(app, BOB, '/bob/notes.txt', 'context'); + const createRes = await app.request( + '/objectives', + authed(BOB, { + title: 'Investigate flake', + outcome: 'root cause + fix', + body: '', + assignee: 'carol', + watchers: ['alice'], + attachments: [ + { path: '/bob/notes.txt', name: 'notes.txt', size: 1, mimeType: 'text/plain' }, + ], + }), + ); + const obj = (await createRes.json()) as Objective; + const namespacePath = `/objectives/${obj.id}/notes.txt`; + + // Originator (bob), assignee (carol), watcher (alice — also admin), + // each can read. + for (const tok of [BOB, CAROL, ALICE]) { + const r = await app.request( + `/fs/stat?path=${encodeURIComponent(namespacePath)}`, + authed(tok), + ); + expect(r.status).toBe(200); + } + // Non-member dave gets 403. + const denied = await app.request( + `/fs/stat?path=${encodeURIComponent(namespacePath)}`, + authed(DAVE), + ); + expect(denied.status).toBe(403); + + // Carol (assignee) can write a follow-up file into the namespace. + const writeRes = await app.request( + `/fs/write?path=${encodeURIComponent(`/objectives/${obj.id}/findings.md`)}&mime=${encodeURIComponent('text/markdown')}`, + { + method: 'POST', + headers: { Authorization: `Bearer ${CAROL}` }, + body: '# findings', + }, + ); + expect(writeRes.status).toBe(200); + + // Dave still can't write into the namespace. + const writeDenied = await app.request( + `/fs/write?path=${encodeURIComponent(`/objectives/${obj.id}/sneaky.md`)}&mime=${encodeURIComponent('text/markdown')}`, + { + method: 'POST', + headers: { Authorization: `Bearer ${DAVE}` }, + body: 'should fail', + }, + ); + expect(writeDenied.status).toBe(403); + }); + + it('drops namespace read access for a watcher the moment they are removed', async () => { + const { app } = makeApp(); + // Set up an objective with dave as a watcher, plus an attachment + // mirrored into the namespace. + await uploadToHome(app, BOB, '/bob/draft.md', 'draft'); + const create = await app.request( + '/objectives', + authed(BOB, { + title: 'Watcher-revoke check', + outcome: 'verified', + body: '', + assignee: 'carol', + watchers: ['dave'], + attachments: [ + { path: '/bob/draft.md', name: 'draft.md', size: 1, mimeType: 'text/markdown' }, + ], + }), + ); + const obj = (await create.json()) as Objective; + const namespacePath = `/objectives/${obj.id}/draft.md`; + + // Dave is a watcher → can read the namespace file. + const beforeRemove = await app.request( + `/fs/stat?path=${encodeURIComponent(namespacePath)}`, + authed(DAVE), + ); + expect(beforeRemove.status).toBe(200); + + // Remove dave from watchers. + const watchers = await app.request( + `/objectives/${obj.id}/watchers`, + authed(BOB, { remove: ['dave'] }), + ); + expect(watchers.status).toBe(200); + + // Access should drop immediately — the namespace ACL consults + // live membership, no grant cleanup needed. + const afterRemove = await app.request( + `/fs/stat?path=${encodeURIComponent(namespacePath)}`, + authed(DAVE), + ); + expect(afterRemove.status).toBe(403); + }); +}); diff --git a/apps/server/test/helpers/test-stores.ts b/apps/server/test/helpers/test-stores.ts index de23098..87b0ee3 100644 --- a/apps/server/test/helpers/test-stores.ts +++ b/apps/server/test/helpers/test-stores.ts @@ -31,7 +31,12 @@ export function mockTeamStore(team: Team): TeamStore { hasTeam: () => true, getPresets: () => ({ ...current.permissionPresets }), setTeam: (input) => { - current = { ...current, name: input.name, directive: input.directive, brief: input.brief }; + current = { + ...current, + name: input.name, + directive: input.directive, + context: input.context, + }; return snapshot(); }, updateTeam: (patch) => { @@ -39,7 +44,7 @@ export function mockTeamStore(team: Team): TeamStore { ...current, name: patch.name ?? current.name, directive: patch.directive ?? current.directive, - brief: patch.brief ?? current.brief, + context: patch.context ?? current.context, }; return snapshot(); }, @@ -86,7 +91,7 @@ export interface SeededStores { export interface SeedTeamInput { name: string; directive: string; - brief?: string; + context?: string; permissionPresets?: Record; } @@ -96,7 +101,7 @@ export function seedStores(input: { team: SeedTeamInput; members: SeedMember[] } stores.team.setTeam({ name: input.team.name, directive: input.team.directive, - brief: input.team.brief ?? '', + context: input.team.context ?? '', }); for (const [name, leaves] of Object.entries(input.team.permissionPresets ?? {})) { stores.team.setPreset(name, leaves); diff --git a/apps/server/test/https.test.ts b/apps/server/test/https.test.ts index 972b813..ca15d8b 100644 --- a/apps/server/test/https.test.ts +++ b/apps/server/test/https.test.ts @@ -28,7 +28,7 @@ const OP_TOKEN = 'ac7_https_test_operator_token'; const TEAM: Team = { name: 'demo-team', directive: 'Verify the HTTPS surface.', - brief: '', + context: '', permissionPresets: {}, }; diff --git a/apps/server/test/members-endpoints.test.ts b/apps/server/test/members-endpoints.test.ts index df75f4b..852c421 100644 --- a/apps/server/test/members-endpoints.test.ts +++ b/apps/server/test/members-endpoints.test.ts @@ -24,7 +24,7 @@ const AGENT_TOKEN = 'ac7_members_test_agent_token'; const TEAM: Team = { name: 'members-team', directive: 'Exercise member CRUD.', - brief: '', + context: '', permissionPresets: { admin: [ 'team.manage', diff --git a/apps/server/test/objectives-endpoints.test.ts b/apps/server/test/objectives-endpoints.test.ts index 4e9f32b..3750119 100644 --- a/apps/server/test/objectives-endpoints.test.ts +++ b/apps/server/test/objectives-endpoints.test.ts @@ -38,7 +38,7 @@ const DAVE = 'ac7_test_dave_secret_token'; const TEAM: Team = { name: 'demo-team', directive: 'Ship the thing.', - brief: '', + context: '', permissionPresets: {}, }; diff --git a/apps/server/test/platform-connect.test.ts b/apps/server/test/platform-connect.test.ts index 2d4dfb0..5622e05 100644 --- a/apps/server/test/platform-connect.test.ts +++ b/apps/server/test/platform-connect.test.ts @@ -29,7 +29,7 @@ const OP_TOKEN = 'ac7_platform_connect_op_token'; const TEAM: Team = { name: 'demo-team', directive: 'Exercise the platform-connect handshake.', - brief: '', + context: '', permissionPresets: {}, }; diff --git a/apps/server/test/presence-busy.test.ts b/apps/server/test/presence-busy.test.ts index b4e92ec..15e8604 100644 --- a/apps/server/test/presence-busy.test.ts +++ b/apps/server/test/presence-busy.test.ts @@ -28,7 +28,7 @@ const AGENT_TOKEN = 'ac7_busy_test_agent_token'; const TEAM: Team = { name: 'busy-test', directive: 'Verify busy presence flow.', - brief: '', + context: '', permissionPresets: {}, }; diff --git a/apps/server/test/push.test.ts b/apps/server/test/push.test.ts index 6ba113a..ea7ba7e 100644 --- a/apps/server/test/push.test.ts +++ b/apps/server/test/push.test.ts @@ -73,7 +73,7 @@ const BOT_TOKEN = 'ac7_push_test_bot_token'; const TEAM: Team = { name: 'demo-team', directive: 'Test push notifications.', - brief: '', + context: '', permissionPresets: {}, }; diff --git a/apps/server/test/run-wiring.test.ts b/apps/server/test/run-wiring.test.ts index 78b8a2f..463635b 100644 --- a/apps/server/test/run-wiring.test.ts +++ b/apps/server/test/run-wiring.test.ts @@ -27,7 +27,7 @@ const ADMIN_TOKEN = 'ac7_run_wiring_test_admin_token'; const TEAM = { name: 'demo-team', directive: 'Verify run.ts wiring.', - brief: '', + context: '', }; const dirsToClean: string[] = []; diff --git a/apps/server/test/session-online.test.ts b/apps/server/test/session-online.test.ts index 3b3b93f..c5db4d0 100644 --- a/apps/server/test/session-online.test.ts +++ b/apps/server/test/session-online.test.ts @@ -76,7 +76,7 @@ const ADMIN_TOKEN = 'ac7_session_online_test_admin_token'; const TEAM: Team = { name: 'session-online-team', directive: 'Verify the session-online notice is gated by auth plane.', - brief: '', + context: '', permissionPresets: {}, }; diff --git a/apps/server/test/shutdown.test.ts b/apps/server/test/shutdown.test.ts index 506b5dd..461bcf4 100644 --- a/apps/server/test/shutdown.test.ts +++ b/apps/server/test/shutdown.test.ts @@ -20,10 +20,10 @@ import { type RunningServer, runServer } from '../src/run.js'; import { seedStores } from './helpers/test-stores.js'; const OP_TOKEN = 'ac7_shutdown_test_op'; -const TEAM: Pick = { +const TEAM: Pick = { name: 'shutdown-test-team', directive: 'Verify shutdown does not hang on live SSE subscribers.', - brief: '', + context: '', }; describe('runServer shutdown with live SSE subscriber', () => { diff --git a/apps/server/test/wizard.test.ts b/apps/server/test/wizard.test.ts index 949755a..06a3eac 100644 --- a/apps/server/test/wizard.test.ts +++ b/apps/server/test/wizard.test.ts @@ -67,14 +67,14 @@ describe('runFirstRunWizard', () => { }; } - // Happy-path script: team name (default), directive, brief (skip), + // Happy-path script: team name (default), directive, context (skip), // admin name (default), role title (default), role description (skip), // press enter after token banner, TOTP code. function happyScript(code: string, overrides: Partial> = {}): string[] { return [ overrides.teamName ?? '', overrides.directive ?? 'Ship the payment service', - overrides.brief ?? '', + overrides.context ?? '', overrides.adminName ?? '', overrides.roleTitle ?? '', overrides.roleDescription ?? '', @@ -91,7 +91,7 @@ describe('runFirstRunWizard', () => { expect(result.team.name).toBe('my-team'); expect(result.team.directive).toBe('Ship the payment service'); - expect(result.team.brief).toBe(''); + expect(result.team.context).toBe(''); expect(result.team.permissionPresets).toBeDefined(); expect(result.admin.name).toBe('director-1'); @@ -128,7 +128,7 @@ describe('runFirstRunWizard', () => { const io = mockIO([ '', // team name 'Ship', // directive - '', // brief + '', // context 'has spaces', // bad name, rejected 'chief', // good name '', // role title (default) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8125ee9..e937303 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -143,6 +143,12 @@ export default defineConfig({ }, server: { port: 5173, + // Bind on all interfaces so on-LAN / tailnet devices (iPhone via + // Tailscale, etc.) can reach the dev server for cross-device + // testing. The dev server is only ever expected to run inside a + // trusted network — not behind a public IP — so the wider bind + // is the right default for an internal tool. + host: true, proxy: deriveProxyRules(), }, }); diff --git a/docs/architecture.mdx b/docs/architecture.mdx index 07ae343..fa5579c 100644 --- a/docs/architecture.mdx +++ b/docs/architecture.mdx @@ -119,7 +119,7 @@ action gates on a specific leaf: | Permission | What it permits | |---|---| -| `team.manage` | Edit team directive / brief / presets | +| `team.manage` | Edit team directive / context / presets | | `members.manage` | Create / update / delete members; rotate any token; reassign objectives | | `objectives.create` | Create + assign objectives | | `objectives.cancel` | Cancel any non-terminal (originator-bypass) | diff --git a/docs/concepts/files.mdx b/docs/concepts/files.mdx new file mode 100644 index 0000000..a662293 --- /dev/null +++ b/docs/concepts/files.mdx @@ -0,0 +1,286 @@ +--- +title: Files +description: ac7's virtual filesystem — per-member homes, objective namespaces, content-addressed blob storage, and the membership-derived ACL that knits attachments into the rest of the team's primitives. +section: Concepts +order: 35 +--- + +# Files + +ac7 has a virtual filesystem built into the broker. It's how +agents and operators upload, share, and reference binary +artifacts — specs, screenshots, exported reports, build outputs — +without standing up a separate object store. + +The shape is intentionally small: + +- **Two top-level scopes**: per-member homes (`//...`) and + per-objective namespaces (`/objectives//...`). +- **Content-addressed blob store** under the metadata layer. + Identical bytes are stored once and referenced by hash; refcount + tracking drops them from disk when the last entry goes away. +- **Membership-derived ACL** for objective namespaces, plus + per-member ownership for homes and an explicit `fs_grants` + table for cross-scope shares. + +This doc explains the model. For wire-level details see the +[REST API reference](/reference/rest-api), and for the agent-side +tool surface see [MCP tools](/reference/mcp-tools). + +## The path model + +Paths are absolute, Unix-like, with `/` as separator. Segments +allow alphanumerics, dot, underscore, hyphen, and single spaces; +`.`/`..` traversal is rejected; no leading or trailing whitespace. + +The first segment classifies the scope: + +| Path | Scope | Owner column | +| ----------------------------- | -------------------- | ------------------- | +| `/` | Synthetic root | (no DB row) | +| `/` | A member's home dir | `` | +| `//...` | Inside that home | `` | +| `/objectives` | Namespace parent dir | `objectives` | +| `/objectives/` | A specific objective | `obj:` | +| `/objectives//...` | Files in that objective | `obj:` | + +Root has no DB row — listing it is a synthetic operation that +returns the top-level directories the viewer can see (their own +home, every home if they're an admin). + +## Two scopes, two ACL stories + +### Member homes + +Every member gets a home at `//`. Inside their home they have +full read, write, mkdir, move, and delete authority. Other members +can't see into someone else's home unless one of three things is +true: + +1. They're an admin (`members.manage` permission). Admins read, + write, and delete anywhere. +2. They hold a **grant** for an exact path (see "Sharing across + scopes" below). +3. The path is in an objective namespace they're a member of. + +Homes are auto-created at member creation time and re-asserted at +broker boot, so a fresh agent can list `//` immediately +without a write-first dance. + +### Objective namespaces + +Every objective has its own writable scope at `/objectives//`. +The ACL is **membership-derived**: any of the originator, the +current assignee, or any watcher gets full read, write, mkdir, +move, and delete authority within the namespace. Add a watcher, +they gain access; remove a watcher, they lose it. No grant rows +need to be touched. + +This is the right scope for files that *belong to* the work, not +to whoever happened to upload them. A spec, a draft PR diff, a +captured logfile, the deliverable itself — these live with the +objective so that: + +- Deleting the uploader's home copy doesn't break the objective. +- Reassignment carries access automatically. +- Files don't get orphaned when an agent is decommissioned. + +The `/objectives` parent directory exists as a routing point but +isn't itself meaningfully writable — only the per-objective +subdirectories underneath it are. Non-admin members can't list +`/objectives` directly; they navigate to their specific +`/objectives//` from the objective's detail view. + +## Storage layer + +Underneath the metadata, ac7 stores file contents in a +content-addressed `BlobStore`. The flow on a write: + +1. The bytes get hashed (SHA-256) and written to disk under the + hash. +2. A row in `fs_entries` records the path, kind, size, mime type, + owner, and `content_hash` — a pointer to the blob. +3. `fs_blobs` tracks `(hash → refcount)`. Refcount goes up by one + per `fs_entries` row that points at the hash. + +On delete, the entry row is removed and the blob's refcount +drops. When refcount hits zero the bytes are evicted from disk. + +This means **identical files are stored once**. If two members +upload the same PDF, or an objective namespace mirrors a member's +home file, only one copy of the bytes lives on disk — the metadata +rows just both point at the same hash. Refcount-aware copy is +how attachment mirroring works: the namespace entry shares the +home entry's hash, so the bytes aren't duplicated even though both +rows are independently deletable. + +## Attachment lifecycle + +Files become attachments when they're referenced from a message, +an objective, or a discussion post. Each path produces a +canonical `Attachment` record: + +```typescript +interface Attachment { + path: string; + name: string; + size: number; + mimeType: string; +} +``` + +The server re-derives `name`, `size`, and `mimeType` from the +underlying entry on every attach call so the request payload +can't lie about what's being shared. The ACL effects depend on +which surface the file is attached to. + +### Objective attachments (create-time) + +When an objective is created with `attachments: [...]`, the +server **mirrors each file into the objective's namespace** by +blob-ref: + +1. Validate the originator can read each claimed path. +2. For each one, create a new entry at + `/objectives//` pointing at the same content + hash. Refcount goes up; bytes don't. +3. Rewrite the objective's `attachments` array to point at the + namespace paths. + +After this step, the objective owns its files in the literal +sense: they live in `/objectives//`, governed by the +membership ACL. The originator's home copy stays put, untouched +— they can delete it later without breaking the objective. + +If a mirror fails (transient FS error, source goes missing +between validation and copy), the whole create surfaces the error +to the caller — there's no half-mirrored fallback that would leave +the objective with a mix of namespace and pointer paths. The +caller retries. + +### Discussion-message attachments + +Posts to an objective's `obj:` discussion thread can carry +attachments too, but those follow the **message attachment** path +(grants with `granted_via = 'msg:'`), not the namespace +mirror. Discussion attachments are conversational artifacts, not +deliverables — the namespace stays focused on files that +represent the work itself. + +### Direct writes into an objective namespace + +Members of an objective can also write into the namespace +directly with `fs_write /objectives//`. No separate +"attach" call is needed — the file is in the namespace and +visible to every member by virtue of the ACL. The objective's +`attachments` JSON column doesn't auto-update on direct writes; +it stays the canonical "create-time deliverables" list. Direct +writes are scratch / collaboration artifacts. + +### Watcher membership changes + +Watcher add/remove has no FS-side bookkeeping: namespace +attachments are gated by `isObjectiveMember` at read time, so +adding a watcher grants access at the moment the membership lands +and removing one revokes it the moment they're gone. No grant +rows to backfill or sweep. + +## Sharing across scopes — the grant table + +The `fs_grants` table records `(path, viewer, granted_via)` +triples. It's how a file in one scope becomes readable from +another. Grants are written when a message is posted with an +attachment — `granted_via = ''` — so every recipient +of the post gets read access to the file the sender attached. +That's the only path that produces grant rows today; objective +namespace access flows through the membership ACL instead, no +grants needed. + +Grants are **read-only** by design. There's no "grant write" — if +a teammate needs to edit a file with you, the right move is to +create a shared scope (objective namespace, eventually a channel +namespace) that both of you are members of, not to hand out +write grants on a personal home file. + +Grants are dropped automatically when: + +- The granted path is deleted (cascade in `fs_rm`). +- The granted path is moved (the move clears and reissues grants + at the new path). + +### Discovery + +Two read-only views surface what a viewer can see beyond their +own home: + +- **`/fs/shared`** — every file shared with the caller via any + grant, deduped by path. Owned by the agent's `fs_shared` tool + and the web UI's "Shared with me" view. +- **`/fs/all`** — admin-only flat list of every file across every + home, newest-first. The web UI surfaces this as the "All files" + tab in the Files panel for members with `members.manage`. Not + exposed as an agent tool — admin agents already have full + navigation authority via `fs_ls /`. + +## Permissions summary + +For a path `P` and viewer `V`, read access requires: + +1. `V` has `members.manage` (admin), OR +2. `V` is the owner of `P` (`ownerOf(P) === V.name`), OR +3. `P` is under an objective namespace `V` is a member of, OR +4. `V` has a grant for `P`. + +Write access drops rule 4 — grants are read-only: + +1. `V` has `members.manage`, OR +2. `V` is the owner of `P`, OR +3. `P` is under an objective namespace `V` is a member of. + +Move and delete follow the write rules. Listing a directory +follows the read rules of that directory. + +The full permission model lives in +[Permissions](/concepts/permissions); files only ever consult +`members.manage` from that surface. + +## Operator and agent surfaces + +Agents interact with the FS through MCP tools — `fs_ls`, +`fs_stat`, `fs_read`, `fs_write`, `fs_mkdir`, `fs_rm`, `fs_mv`, +`fs_shared`. The full schema is in +[MCP tools](/reference/mcp-tools); each tool's description +mentions which scopes it applies to. + +Operators see files via the web UI's Files panel, which mounts +the same `/fs/*` HTTP surface as the agents. Three modes: + +- **Tree** — navigate the path tree starting at root. The viewer + sees their own home; admins see every home. +- **Shared with me** — flat list backed by `/fs/shared`. Files + attached to threads the viewer participates in. +- **All files** *(admin only)* — flat list backed by `/fs/all`. + Every file across every home and namespace, newest-first. + +There is no single CLI verb for files; the web UI and the agent +tools cover the cases. For one-off operator scripting, the +[REST API](/reference/rest-api#filesystem) endpoints can be +called directly with the operator's token. + +## What's next + +The current model covers per-member homes and per-objective +namespaces. Two natural extensions follow the same membership- +derived pattern: + +- **`/team/...`** — a team-wide writable scope, governed by team + membership. Lets the team share files that aren't tied to a + specific objective. +- **`/channels//...`** — per-channel scopes, governed by + channel membership. Replaces "drop the file in your home and + attach it" with "save the file in the channel." + +Both reuse the same primitives proven by the objective namespace +— a non-individual owner, a membership-driven ACL, mirroring at +the attach point. Neither is shipped yet; this section will +update when they are. diff --git a/docs/concepts/permissions.mdx b/docs/concepts/permissions.mdx index 2bd0468..c6d3529 100644 --- a/docs/concepts/permissions.mdx +++ b/docs/concepts/permissions.mdx @@ -24,7 +24,7 @@ touch other members or shape the team itself. | Permission | What it permits | |---|---| -| **`team.manage`** | Edit `team.directive`, `team.brief`, and `team.permissionPresets`. | +| **`team.manage`** | Edit `team.directive`, `team.context`, and `team.permissionPresets`. | | **`members.manage`** | Create / update / delete members. Rotate any member's bearer token. Approve / reject pending enrollments. Add / remove channel members. Reassign any objective. | | **`objectives.create`** | Create objectives and assign them to any teammate. The originator is stamped as the caller. | | **`objectives.cancel`** | Cancel any non-terminal objective. (Originators can always cancel their own.) | diff --git a/docs/reference/config.mdx b/docs/reference/config.mdx index 873f22f..b1a6223 100644 --- a/docs/reference/config.mdx +++ b/docs/reference/config.mdx @@ -32,7 +32,7 @@ broker's SQLite. "team": { "name": "platform-eng", "directive": "Ship the v2 ingest pipeline by Q3", - "brief": "Multi-paragraph context...", + "context": "Multi-paragraph team context...", "permissionPresets": { "admin": ["team.manage", "members.manage", "objectives.create", "objectives.cancel", "objectives.reassign", diff --git a/docs/reference/rest-api.mdx b/docs/reference/rest-api.mdx index a82e6f9..cac94f1 100644 --- a/docs/reference/rest-api.mdx +++ b/docs/reference/rest-api.mdx @@ -64,7 +64,7 @@ Liveness probe. Cheap; always responds 200 if the process is up. The team-context packet. Returns the caller's full member record (name, role, instructions, permissions) plus the team -(name, directive, brief, permissionPresets), the public projection +(name, directive, context, permissionPresets), the public projection of every teammate, and the caller's currently-open objectives. The runner caches this at startup and uses it for the entire diff --git a/docs/runners/overview.mdx b/docs/runners/overview.mdx index 40058a9..e656beb 100644 --- a/docs/runners/overview.mdx +++ b/docs/runners/overview.mdx @@ -61,7 +61,7 @@ Concretely, on every runner startup: 1. **Authenticate** with `--token` / `$AC7_TOKEN` / `~/.config/ac7/auth.json`. 2. **Fetch the briefing** — the member's name, role, permissions, - teammates, team directive + brief, and currently-open objectives. + teammates, team directive + context, and currently-open objectives. 3. **Bind the IPC socket** at `$TMPDIR/.ac7-runner-.sock` (overridable). The agent's MCP bridge subprocess connects back to this. diff --git a/packages/cli/src/commands/auth-config.ts b/packages/cli/src/commands/auth-config.ts index 8c6fc4c..3794622 100644 --- a/packages/cli/src/commands/auth-config.ts +++ b/packages/cli/src/commands/auth-config.ts @@ -1,12 +1,25 @@ /** * Client-side auth config — `(broker URL, bearer token)` persisted at - * `~/.config/ac7/auth.json` after a successful `ac7 connect` run. + * `./.ac7/auth.json` (project-scoped, sibling to `ac7.json`) after a + * successful `ac7 connect` run. + * + * Project-scoped (not user-scoped) so a single machine can hold a + * distinct member identity per agent workspace — running `ac7 connect` + * in `~/projects/agent-a/` and `~/projects/agent-b/` produces two + * independent enrollments without one stomping the other. + * + * Lookup uses git-style walk-up: starting at cwd, we walk parents + * until a `.ac7/auth.json` is found, so any `ac7` command run from a + * subdirectory of an enrolled project picks up the right token. Saves + * also walk up first — re-running `ac7 connect` from a subfolder + * updates the project's existing entry instead of creating a stray + * config in the wrong place. Only when nothing is found does the save + * land in cwd. * * The CLI defaults to the env-var path (`AC7_URL` / `AC7_TOKEN`) * when both are present, and falls back to this file when they're - * absent. That keeps existing CI / scripted setups working as-is - * while giving operators the gh-style "log in once" experience for - * machine-issued tokens. + * absent. `AC7_AUTH_CONFIG_PATH` overrides everything (used by tests + * and air-gapped layouts). * * Storage shape is intentionally minimal — one entry per broker * URL, the most-recent write wins for the same URL. We do NOT @@ -19,9 +32,8 @@ * `ac7.json` and the telemetry path. */ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, parse as parsePath, resolve } from 'node:path'; export interface AuthConfigEntry { url: string; @@ -36,30 +48,56 @@ interface AuthConfigFile { entries: AuthConfigEntry[]; } +/** The directory + filename used at every level of the walk-up search. */ +const AUTH_DIR = '.ac7'; +const AUTH_FILE = 'auth.json'; + /** - * Resolve the auth-config file path. `AC7_AUTH_CONFIG_PATH` overrides - * for tests and air-gapped layouts. + * The path where a fresh `ac7 connect` writes when no existing config + * is discovered upward — always cwd-scoped. `AC7_AUTH_CONFIG_PATH` + * overrides for tests and air-gapped layouts. */ export function authConfigPath(): string { const override = process.env.AC7_AUTH_CONFIG_PATH; if (override) return override; - const home = homedir(); - if (process.platform === 'win32') { - const appdata = process.env.APPDATA ?? join(home, 'AppData', 'Roaming'); - return join(appdata, 'ac7', 'auth.json'); - } - if (process.platform === 'darwin') { - return join(home, 'Library', 'Application Support', 'ac7', 'auth.json'); + return join(process.cwd(), AUTH_DIR, AUTH_FILE); +} + +/** + * Walk up from `start` (default cwd) looking for the closest + * `.ac7/auth.json`. Returns its path if found, otherwise null. + * Stops at the filesystem root. The env override short-circuits the + * walk so test sandboxes stay isolated. + */ +export function findAuthConfigPath(start: string = process.cwd()): string | null { + const override = process.env.AC7_AUTH_CONFIG_PATH; + if (override) return existsSync(override) ? override : null; + let dir = resolve(start); + const root = parsePath(dir).root; + // `root` itself is a valid place to look — git puts no `.git` at `/` + // by convention, but we don't impose that, so include it. + while (true) { + const candidate = join(dir, AUTH_DIR, AUTH_FILE); + if (existsSync(candidate)) return candidate; + if (dir === root) return null; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; } - const xdg = process.env.XDG_CONFIG_HOME ?? join(home, '.config'); - return join(xdg, 'ac7', 'auth.json'); } -/** Read every saved entry. Empty list on missing file or unrecognized shape. */ -export function loadAuthConfig(path: string = authConfigPath()): AuthConfigFile { +/** + * Read every saved entry from the closest project-scoped config + * found via walk-up. Empty list on no file or unrecognized shape. + * Pass `path` to read from a specific file (used by tests and the + * `--auth-config` flag). + */ +export function loadAuthConfig(path?: string): AuthConfigFile { + const resolved = path ?? findAuthConfigPath(); + if (resolved === null) return { schema: 1, entries: [] }; let raw: string; try { - raw = readFileSync(path, 'utf8'); + raw = readFileSync(resolved, 'utf8'); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'ENOENT') return { schema: 1, entries: [] }; @@ -92,25 +130,30 @@ export function loadAuthConfig(path: string = authConfigPath()): AuthConfigFile * Atomically replace the entry for `url` (or insert if new) and * write back at 0o600. `mkdir -p` the containing dir at 0o700 so a * fresh install can save without an explicit setup step. + * + * Without an explicit `path`, save targets an existing project-level + * config found via walk-up (so re-running `ac7 connect` from a + * subdirectory updates the project's config rather than scattering + * a new `.ac7/` next to wherever the operator happens to be). Falls + * back to the cwd-scoped path when no existing config is found. */ -export function saveAuthEntry(entry: AuthConfigEntry, path: string = authConfigPath()): void { - const file = loadAuthConfig(path); +export function saveAuthEntry(entry: AuthConfigEntry, path?: string): void { + const target = path ?? findAuthConfigPath() ?? authConfigPath(); + const file = loadAuthConfig(target); const next: AuthConfigEntry[] = file.entries.filter((e) => e.url !== entry.url); next.push(entry); const out: AuthConfigFile = { schema: 1, entries: next }; - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`, { mode: 0o600 }); + mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); + writeFileSync(target, `${JSON.stringify(out, null, 2)}\n`, { mode: 0o600 }); } /** * Find a saved token for `url` (exact match — we do not normalize - * trailing slashes here; the SDK Client does). Returns null if no - * entry matches. + * trailing slashes here; the SDK Client does). Walks up from cwd to + * find the closest project-scoped config. Returns null if no entry + * matches. */ -export function findAuthEntry( - url: string, - path: string = authConfigPath(), -): AuthConfigEntry | null { +export function findAuthEntry(url: string, path?: string): AuthConfigEntry | null { const file = loadAuthConfig(path); return file.entries.find((e) => e.url === url) ?? null; } diff --git a/packages/cli/src/commands/connect.ts b/packages/cli/src/commands/connect.ts index fa3beac..f0447bb 100644 --- a/packages/cli/src/commands/connect.ts +++ b/packages/cli/src/commands/connect.ts @@ -4,13 +4,16 @@ * Replaces the "create a member, copy the token, paste it into your * config" onboarding flow with the gh-auth-style two-leg handshake: * + * 0. Prompt for the broker URL when neither `--url` nor `AC7_URL` + * was provided (default `http://127.0.0.1:8717`). A bare Enter + * accepts the default. * 1. CLI POSTs /enroll → broker mints (deviceCode, userCode) * 2. CLI prints `userCode` + verification URL to the operator, * then polls /enroll/poll every `interval` seconds * 3. Director, signed in via TOTP at the broker URL, types the * code into the SPA and approves * 4. CLI's next poll resolves with the token; CLI persists it to - * `~/.config/ac7/auth.json` and exits + * `./.ac7/auth.json` (project-scoped) and exits * * The bearer token plaintext is never echoed to either operator's * terminal scrollback — it goes straight from the broker to the CLI's @@ -24,9 +27,10 @@ * director exists yet to approve). */ +import { relative } from 'node:path'; import { Client, ClientError } from '@agentc7/sdk/client'; import { DEFAULT_PORT, ENV } from '@agentc7/sdk/protocol'; -import { authConfigPath, saveAuthEntry } from './auth-config.js'; +import { authConfigPath, findAuthConfigPath, saveAuthEntry } from './auth-config.js'; import { UsageError } from './errors.js'; export { UsageError }; @@ -52,6 +56,13 @@ export interface ConnectCommandInput { fetch?: typeof fetch; /** Test-only clock injection. */ now?: () => number; + /** + * Prompt the operator for a single line of input. Used to ask for + * the broker URL when neither `--url` nor `AC7_URL` was provided. + * Returns the raw answer (no trim). Tests inject this; in production + * we wire up a readline-backed default. + */ + prompt?: (question: string) => Promise; } export interface ConnectCommandOutput { @@ -108,9 +119,20 @@ export async function runConnectCommand( stderr: (line: string) => void, abortSignal?: AbortSignal, ): Promise { - const url = input.url ?? process.env[ENV.url] ?? `http://127.0.0.1:${DEFAULT_PORT}`; + // `--url` flag and `AC7_URL` env both bypass the prompt — they're + // explicit "use this URL" signals (CI, scripts, sticky shell env). + // Only with neither do we run the wizard; a bare Enter accepts the + // local-broker default. The banner that follows shows the URL so + // the operator still gets eyes-on confirmation before approving. + const defaultUrl = `http://127.0.0.1:${DEFAULT_PORT}`; + let url = input.url ?? process.env[ENV.url]; + if (!url) { + const ask = input.prompt ?? defaultPrompt; + const answer = (await ask(`Broker URL [${defaultUrl}]: `)).trim(); + url = answer.length > 0 ? answer : defaultUrl; + } if (!/^https?:\/\//.test(url)) { - throw new UsageError(`connect: --url must be http(s)://… (got '${url}')`); + throw new UsageError(`connect: URL must be http(s)://… (got '${url}')`); } // Use the SDK Client with skipAuth on the calls that need it. @@ -239,7 +261,7 @@ export async function runConnectCommand( stdout(` token: ${data.token}`); } else { stdout(` saved to: ${authConfigDisplayPath(input.authConfigPath)}`); - stdout(` next: ac7 claude-code`); + stdout(` next: ac7 claude-code (or: ac7 codex)`); } return { url, @@ -255,6 +277,24 @@ export async function runConnectCommand( } } +/** + * Readline-backed prompt for the broker URL. Stays inline (no shared + * wizard IO module) since `connect` only ever asks one question; the + * dynamic `node:readline` import keeps the cold-start cost off the + * non-interactive paths (`--url` flag / `AC7_URL` env). + */ +async function defaultPrompt(question: string): Promise { + const { createInterface } = await import('node:readline'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + return await new Promise((resolve) => { + rl.question(question, (answer) => resolve(answer)); + }); + } finally { + rl.close(); + } +} + /** * Default label hint when the operator didn't pass `--label`. Prefer * `$HOSTNAME` when set (the actual machine name); fall back to a @@ -267,12 +307,14 @@ function defaultLabelHint(): string { } /** - * Render the auth-config path for display, expanding $HOME → `~` so - * the line in the success output reads naturally. + * Render the auth-config path for display, preferring a `./`-prefixed + * relative path when the file lives under cwd (the common case for + * project-scoped configs). Falls back to absolute when the file lives + * above cwd (re-`connect` from a subdir updating the project root). */ function authConfigDisplayPath(override: string | undefined): string { - const path = override ?? authConfigPath(); - const home = process.env.HOME; - if (home && path.startsWith(home)) return `~${path.slice(home.length)}`; + const path = override ?? findAuthConfigPath() ?? authConfigPath(); + const rel = relative(process.cwd(), path); + if (!rel.startsWith('..') && !rel.startsWith('/')) return `./${rel}`; return path; } diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 2cccf27..e5a31b7 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -174,7 +174,7 @@ async function runWizardOrFail( stores.team.setTeam({ name: wizard.team.name, directive: wizard.team.directive, - brief: wizard.team.brief, + context: wizard.team.context, }); for (const [name, leaves] of Object.entries(wizard.team.permissionPresets)) { stores.team.setPreset(name, leaves); diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index b2c38a5..b15e5f4 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -112,7 +112,7 @@ export async function runSetupCommand( stores.team.setTeam({ name: wizard.team.name, directive: wizard.team.directive, - brief: wizard.team.brief, + context: wizard.team.context, }); for (const [name, leaves] of Object.entries(wizard.team.permissionPresets)) { stores.team.setPreset(name, leaves); diff --git a/packages/cli/src/commands/team.ts b/packages/cli/src/commands/team.ts index 428678c..d5ab275 100644 --- a/packages/cli/src/commands/team.ts +++ b/packages/cli/src/commands/team.ts @@ -3,13 +3,13 @@ * * Subcommands: * ac7 team get - * ac7 team set [--name ] [--directive ] [--brief ] + * ac7 team set [--name ] [--directive ] [--context ] * * Talks to the running broker via the HTTP API. Mutations require * the calling member to have `team.manage`. Changes apply * immediately on the server side; agents already in an MCP session * still need a runner restart to pick up changes that flow into the - * MCP `instructions` string (directive, brief), since that string is + * MCP `instructions` string (directive, context), since that string is * frozen for the lifetime of a session by the MCP protocol. */ @@ -56,11 +56,11 @@ async function runGet( } stdout(`name ${team.name}`); stdout(`directive ${team.directive}`); - stdout('brief'); - if (team.brief.trim().length === 0) { + stdout('context'); + if (team.context.trim().length === 0) { stdout(' (none)'); } else { - for (const line of team.brief.split('\n')) stdout(` ${line}`); + for (const line of team.context.split('\n')) stdout(` ${line}`); } const presetNames = Object.keys(team.permissionPresets); stdout(`presets ${presetNames.length === 0 ? '(none)' : presetNames.join(', ')}`); @@ -76,22 +76,22 @@ async function runSet( options: { name: { type: 'string' }, directive: { type: 'string' }, - brief: { type: 'string' }, - 'brief-file': { type: 'string' }, + context: { type: 'string' }, + 'context-file': { type: 'string' }, }, allowPositionals: false, }); - const patch: { name?: string; directive?: string; brief?: string } = {}; + const patch: { name?: string; directive?: string; context?: string } = {}; if (typeof values.name === 'string') patch.name = values.name; if (typeof values.directive === 'string') patch.directive = values.directive; - if (typeof values.brief === 'string') patch.brief = values.brief; - if (typeof values['brief-file'] === 'string') { + if (typeof values.context === 'string') patch.context = values.context; + if (typeof values['context-file'] === 'string') { const { readFileSync } = await import('node:fs'); - patch.brief = readFileSync(values['brief-file'], 'utf8'); + patch.context = readFileSync(values['context-file'], 'utf8'); } if (Object.keys(patch).length === 0) { throw new UsageError( - 'team set requires at least one of --name, --directive, --brief, --brief-file', + 'team set requires at least one of --name, --directive, --context, --context-file', ); } const team = await client.updateTeam(patch); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 9ab149c..01a47f7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -124,6 +124,48 @@ function resolveAuth(input: { url?: string; token?: string }): { return { url, token }; } +/** + * Same as `resolveAuth` but runs the device-code `ac7 connect` flow + * inline when no token can be resolved, returning the freshly-minted + * token instead of failing. Used by the long-running runner verbs + * (`ac7 claude-code`, `ac7 codex`) where the natural UX on first run + * is "set me up, then start the session" rather than bouncing the + * operator out to a separate command. + * + * Single-use verbs (push, roster, objectives) keep the hard fail — + * those are typically scripted, and prompting from the middle of a + * pipeline would be surprising. + */ +async function resolveAuthOrConnect(input: { url?: string; token?: string }): Promise<{ + url: string; + token: string; +}> { + const url = input.url ?? process.env[ENV.url] ?? `http://127.0.0.1:${DEFAULT_PORT}`; + let token = input.token ?? process.env[ENV.token]; + if (!token) { + const saved = findAuthEntry(url); + if (saved) token = saved.token; + } + if (token) return { url, token }; + + // No auth in this project. Fall through to the device-code flow. + // Pass `input.url` through (not the resolved fallback) so connect + // re-runs its own resolution — that way an unset `--url` still + // triggers connect's own prompt path with the right default. + process.stdout.write('ac7: no saved auth for this directory — running `ac7 connect`...\n'); + try { + const result = await runConnectCommand( + input.url !== undefined ? { url: input.url } : {}, + (line) => process.stdout.write(`${line}\n`), + (line) => process.stderr.write(`${line}\n`), + ); + return { url: result.url, token: result.token }; + } catch (err) { + if (err instanceof UsageError) fail(err.message, 2); + fail(err instanceof Error ? err.message : String(err)); + } +} + async function main(): Promise { const argv = process.argv.slice(2); if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') { @@ -745,7 +787,7 @@ async function handleClaudeCode(args: string[]): Promise { } try { - const resolved = resolveAuth({ url, token }); + const resolved = await resolveAuthOrConnect({ url, token }); const code = await runClaudeCodeCommand({ url: resolved.url, token: resolved.token, @@ -820,7 +862,7 @@ async function handleCodex(args: string[]): Promise { } try { - const resolved = resolveAuth({ url, token }); + const resolved = await resolveAuthOrConnect({ url, token }); const code = await runCodexCommand({ url: resolved.url, token: resolved.token, diff --git a/packages/cli/src/runtime/tools.ts b/packages/cli/src/runtime/tools.ts index b60f971..8804214 100644 --- a/packages/cli/src/runtime/tools.ts +++ b/packages/cli/src/runtime/tools.ts @@ -56,8 +56,8 @@ export function defineTools(briefing: BriefingResponse): Tool[] { name: 'roster', description: `List all teammates currently on the ac7 net. You go by ${identity} in ` + - `team ${team.name}. Directive: ${team.directive}. Returns each teammate's ` + - `name, role, authority, and connection state.`, + `team ${team.name}. Returns each teammate's name, role, authority, and ` + + `connection state.`, inputSchema: { type: 'object', properties: {} }, }, { @@ -69,12 +69,12 @@ export function defineTools(briefing: BriefingResponse): Tool[] { `named channel's members, use \`channels_post\` instead — \`broadcast\` always goes ` + `to general. You go by ${identity}. Teammates: ${teammateList}. Optionally attach ` + `files from your home (\`/${name}/...\`); recipients automatically receive read access ` + - `to each attached path via the resulting message.`, + `to each attached path via the resulting message. Returns delivery counts (live ` + + `subscribers, addressed targets) and the new message id.`, inputSchema: { type: 'object', properties: { body: { type: 'string', description: 'The message body the team will receive.' }, - title: { type: 'string', description: 'Optional short title / subject line.' }, level: { type: 'string', enum: [...LEVELS], @@ -95,14 +95,14 @@ export function defineTools(briefing: BriefingResponse): Tool[] { description: `Send a direct message to a specific teammate on ${team.name}. Messages are ` + `private to you and the target. You go by ${identity}. Available names: ` + - `${teammateList}. Directive: ${team.directive}. Optionally attach files from ` + - `your home; the recipient receives read access to each attached path.`, + `${teammateList}. Optionally attach files from your home; the recipient ` + + `receives read access to each attached path. Returns delivery counts and the ` + + `new message id.`, inputSchema: { type: 'object', properties: { to: { type: 'string', description: 'The name of the teammate to message.' }, body: { type: 'string', description: 'The message body.' }, - title: { type: 'string', description: 'Optional short title / subject line.' }, level: { type: 'string', enum: [...LEVELS], @@ -138,7 +138,8 @@ export function defineTools(briefing: BriefingResponse): Tool[] { `team. You must already be a member of the channel; ask a director to add you if ` + `not. You go by ${identity}. Optionally attach files from your home; channel members ` + `receive read access to each attached path. To find available channels run ` + - `\`channels_list\`. To post to the team-wide general channel use \`broadcast\`.`, + `\`channels_list\`. To post to the team-wide general channel use \`broadcast\`. ` + + `Returns delivery counts and the new message id.`, inputSchema: { type: 'object', properties: { @@ -147,7 +148,6 @@ export function defineTools(briefing: BriefingResponse): Tool[] { description: 'Channel slug (e.g. "frontend", "ops"). Must be a channel you belong to.', }, body: { type: 'string', description: 'The message body.' }, - title: { type: 'string', description: 'Optional short title / subject line.' }, level: { type: 'string', enum: [...LEVELS], @@ -167,11 +167,10 @@ export function defineTools(briefing: BriefingResponse): Tool[] { name: 'recent', description: `Fetch recent messages from the ${team.name} team's general channel, a specific ` + - `DM thread, or a named channel. You go by ${identity}. Team directive: ` + - `${team.directive}. Pass exactly one of: \`with=NAME\` for DMs with that teammate, ` + - `\`channel=SLUG\` for a named channel's scrollback, or no scope arg for the general ` + - `team channel. Returns messages newest-first up to ${DEFAULT_RECENT_LIMIT} by ` + - `default (max ${MAX_RECENT_LIMIT}).`, + `DM thread, or a named channel. You go by ${identity}. Pass exactly one of: ` + + `\`with=NAME\` for DMs with that teammate, \`channel=SLUG\` for a named channel's ` + + `scrollback, or no scope arg for the general team channel. Returns messages ` + + `newest-first up to ${DEFAULT_RECENT_LIMIT} by default (max ${MAX_RECENT_LIMIT}).`, inputSchema: { type: 'object', properties: { @@ -199,7 +198,8 @@ export function defineTools(briefing: BriefingResponse): Tool[] { `assigned to you, originated by you, or objectives you're watching. ` + `Use \`status\` to filter (active | blocked | done | cancelled); omit to see all ` + `statuses. Objectives always carry a required outcome — use \`objectives_view\` ` + - `for full detail including the watcher list and audit log.`, + `for full detail including the watcher list and audit log. Returns each objective's ` + + `id, title, outcome, status, assignee, originator, and timestamps.`, inputSchema: { type: 'object', properties: { @@ -215,10 +215,11 @@ export function defineTools(briefing: BriefingResponse): Tool[] { { name: 'objectives_view', description: - `Fetch the full state of a single objective including its outcome, current status, ` + - `block reason (if any), and the append-only event history. Use this before calling ` + + `Fetch the full state of a single objective. Use this before calling ` + `\`objectives_update\` or \`objectives_complete\` so you have the latest acceptance ` + - `criteria fresh in context.`, + `criteria fresh in context. Returns the full objective record (id, title, outcome, ` + + `body, status, assignee, originator, watchers, attachments, block reason if any, ` + + `result if completed) plus the append-only event log.`, inputSchema: { type: 'object', properties: { @@ -235,7 +236,8 @@ export function defineTools(briefing: BriefingResponse): Tool[] { `block. This tool is for STATE transitions only — for progress notes, questions, ` + `intermediate findings, or any conversation about the objective, use ` + `\`objectives_discuss\` to post into the objective's discussion thread. This tool ` + - `never transitions to 'done' — call \`objectives_complete\` for that.`, + `never transitions to 'done' — call \`objectives_complete\` for that. Returns the ` + + `updated objective.`, inputSchema: { type: 'object', properties: { @@ -264,7 +266,8 @@ export function defineTools(briefing: BriefingResponse): Tool[] { `findings, coordination with the originator, or acknowledgments — anything that's ` + `conversation rather than a state transition. Every post is archived alongside ` + `the objective's event log and is visible in the web UI's inline thread view. ` + - `Optionally attach files from your home; thread members receive automatic read access.`, + `Optionally attach files from your home; thread members receive automatic read access. ` + + `Returns the new message id.`, inputSchema: { type: 'object', properties: { @@ -273,10 +276,6 @@ export function defineTools(briefing: BriefingResponse): Tool[] { type: 'string', description: 'The message body to post into the objective thread.', }, - title: { - type: 'string', - description: 'Optional short title / subject line.', - }, attachments: { type: 'array', items: { type: 'string' }, @@ -293,7 +292,8 @@ export function defineTools(briefing: BriefingResponse): Tool[] { `Mark an objective as done with a required result summary. Call ` + `\`objectives_view\` first to refresh the acceptance criteria in context. The ` + `\`result\` should explicitly address whether the stated outcome was met and link ` + - `or describe the deliverable. Only the current assignee may call this.`, + `or describe the deliverable. Only the current assignee may call this. Returns ` + + `the now-completed objective with its \`completedAt\` and \`result\` filled in.`, inputSchema: { type: 'object', properties: { @@ -314,6 +314,14 @@ export function defineTools(briefing: BriefingResponse): Tool[] { // require either a grant (the file was attached to a message you // can see) or director authority. See `fs_shared` for a list of // files shared with you. + // + // Objective namespaces live at `/objectives//` and are + // collaboratively read/write/delete-able by every member of the + // objective (originator + assignee + watchers). Files attached at + // objective-create time are mirrored into this namespace + // automatically — agents who are members can also `fs_write` + // additional files there directly, and they participate in the + // same membership ACL. ...buildFilesystemTools(name), // ── UserType-gated tools ──────────────────────────────────────── // @@ -359,31 +367,33 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] { tools.push({ name: 'team_get', description: - 'Read the current team config (name, directive, brief, permission presets). ' + - 'Use this to confirm team state before proposing edits, or to check whether ' + - 'a previous `team_update` landed.', + 'Read the current team config: returns name, directive, context, and the named ' + + 'permission presets. Use this to confirm team state before proposing edits, or ' + + 'to check whether a previous `team_update` landed.', inputSchema: { type: 'object', properties: {} }, }); tools.push({ name: 'team_update', description: - 'Update one or more team-level fields. `directive` and `brief` change the ' + - 'context every member is briefed with on subsequent MCP sessions; live ' + - 'sessions still reflect the OLD strings until the runner restarts (the MCP ' + + 'Update one or more team-level fields. `directive` and `context` change the ' + + 'briefing every member is shown on subsequent MCP sessions; live sessions ' + + 'still reflect the OLD strings until the runner restarts (the MCP ' + '`instructions` field is frozen for the lifetime of a session by protocol). ' + - 'Authority: requires `team.manage`. Pass at least one of `name`, `directive`, ' + - '`brief`.', + 'Pass at least one of `name`, `directive`, `context`. Returns the updated team ' + + 'config (same shape as `team_get`).', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'New team name (1–128 chars).' }, directive: { type: 'string', - description: "New short directive (1–512 chars). Set the team's overarching mission.", + description: + "New short directive (1–512 chars). The team's standing imperative — what the team is here to do.", }, - brief: { + context: { type: 'string', - description: 'New longer brief (≤ 4096 chars). Operating context.', + description: + 'New longer team context (≤ 4096 chars). Background paragraph that complements the directive.', }, }, }, @@ -392,7 +402,9 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] { // ─── Permission presets ────────────────────────────────────── tools.push({ name: 'presets_list', - description: "List the team's permission presets — named bundles of leaf permissions.", + description: + "List the team's permission presets — named bundles of leaf permissions. " + + 'Returns each preset as `{ name, permissions[] }`.', inputSchema: { type: 'object', properties: {} }, }); tools.push({ @@ -400,7 +412,7 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] { description: 'Create or replace a permission preset. Members that reference this preset by ' + 'name in their raw permissions automatically pick up the new leaf set on the next ' + - 'read — no member-by-member re-resolve required.', + 'read — no member-by-member re-resolve required. Returns the upserted preset.', inputSchema: { type: 'object', properties: { @@ -421,9 +433,9 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] { tools.push({ name: 'presets_delete', description: - 'Delete a permission preset. Returns the names of members that still reference it; ' + - 'their resolved permissions silently drop those leaves on the next read. Use this ' + - 'with intent — there is no soft-delete.', + 'Delete a permission preset. Use this with intent — there is no soft-delete. ' + + 'Returns the names of members that still reference the deleted preset (their ' + + 'resolved permissions silently drop those leaves on the next read).', inputSchema: { type: 'object', properties: { @@ -439,9 +451,10 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] { tools.push({ name: 'members_add', description: - 'Create a new team member. Returns the plaintext bearer token exactly once — ' + - 'capture it from the response and deliver it to the operator/agent securely. ' + - '`permissions` accepts preset names (e.g. "admin", "operator") or leaf permissions.', + 'Create a new team member. `permissions` accepts preset names (e.g. "admin", ' + + '"operator") or leaf permissions. Returns the new member plus the plaintext ' + + 'bearer token (emitted exactly once — capture it from the response and deliver ' + + 'it to the operator/agent securely).', inputSchema: { type: 'object', properties: { @@ -469,7 +482,8 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] { description: "Update an existing member's role, instructions, or permissions. Changes to " + "`instructions` apply to that member's NEXT MCP session — the current session " + - 'continues to reflect the old briefing until the runner restarts.', + 'continues to reflect the old briefing until the runner restarts. Returns the ' + + 'updated member record (no token, no totp secret — those are not re-emitted).', inputSchema: { type: 'object', properties: { @@ -491,7 +505,8 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] { name: 'members_remove', description: 'Delete a member. All bearer tokens for the member are revoked. Refused if this ' + - 'would leave the team with zero members holding `members.manage`.', + 'would leave the team with zero members holding `members.manage`. Returns nothing ' + + 'on success.', inputSchema: { type: 'object', properties: { @@ -513,7 +528,9 @@ function buildFilesystemTools(name: string): Tool[] { description: `List the contents of a directory in the ac7 virtual filesystem. ` + `Your home is \`${home}\`; passing "/" lists the set of homes you can see. ` + - `Entries include per-item metadata (kind, size, mime type, owner).`, + `Entries include per-item metadata (kind, size, mime type, owner). ` + + `Objective namespaces are listable at \`/objectives//\` if you're a ` + + `member (originator, assignee, or watcher) of that objective.`, inputSchema: { type: 'object', properties: { @@ -555,13 +572,18 @@ function buildFilesystemTools(name: string): Tool[] { `Upload a file. Pass EITHER \`text\` (UTF-8 string) or \`base64\` (for binary ` + `content), never both. Parent directories are auto-created. By default errors on ` + `collision; use collide="suffix" to auto-rename ("foo.txt" → "foo-1.txt") or ` + - `"overwrite" to replace the existing file. You go by ${name}; your home is ${home}.`, + `"overwrite" to replace the existing file. You go by ${name}; your home is ${home}. ` + + `Returns the resulting FsEntry (path, name, size, mime, owner) — note the path ` + + `may differ from the requested one when collide="suffix" produced a rename.`, inputSchema: { type: 'object', properties: { path: { type: 'string', - description: `Absolute path to write (must sit under ${home} unless you are a director).`, + description: + `Absolute path to write. Allowed under ${home} (your home), ` + + `under \`/objectives//\` for any objective you're a member of, ` + + `or anywhere if you're a director.`, }, mimeType: { type: 'string', @@ -588,7 +610,7 @@ function buildFilesystemTools(name: string): Tool[] { name: 'fs_mkdir', description: `Create a directory. Pass recursive=true to auto-create missing parents. ` + - `You go by ${name}; your home is ${home}.`, + `You go by ${name}; your home is ${home}. Returns the directory's FsEntry.`, inputSchema: { type: 'object', properties: { @@ -603,7 +625,7 @@ function buildFilesystemTools(name: string): Tool[] { description: `Remove a file or directory. Directories require recursive=true if non-empty. ` + `Deletion cascades blob refcounts — the underlying content is purged only when the ` + - `last referencing entry across the filesystem goes away.`, + `last referencing entry across the filesystem goes away. Returns nothing on success.`, inputSchema: { type: 'object', properties: { @@ -620,7 +642,8 @@ function buildFilesystemTools(name: string): Tool[] { name: 'fs_mv', description: `Rename / move a file. Directory moves are not currently supported. ` + - `Both the source and destination must sit under a tree you own (or you must be a director).`, + `Both the source and destination must sit under a tree you own (or you must be a director). ` + + `Returns the FsEntry at the destination path.`, inputSchema: { type: 'object', properties: { @@ -633,9 +656,13 @@ function buildFilesystemTools(name: string): Tool[] { { name: 'fs_shared', description: - `List every file that has been shared with you via a message or objective ` + - `attachment. Owner-private files from other slots never appear here — only ones ` + - `a teammate explicitly attached to a thread you can see.`, + `List every file that has been shared with you via a message attachment — ` + + `entries another member explicitly attached to a thread you can see. Owner- ` + + `private files from other slots never appear here. Files that live in objective ` + + `namespaces you're a member of (\`/objectives//...\`) are NOT in this list ` + + `either; access there flows from membership, not grants — use \`fs_ls\` on ` + + `that namespace path to see them. Returns each file's FsEntry (path, size, mime, ` + + `owner).`, inputSchema: { type: 'object', properties: {} }, }, ]; @@ -672,7 +699,8 @@ function buildAuthorityTools(briefing: BriefingResponse): Tool[] { `contractual: it must state the tangible, verifiable result that defines "done", not ` + `just a vague intent. Optionally include a \`body\` for additional context and ` + `\`watchers\` (a list of names) to loop other teammates into the discussion thread ` + - `from the start. Available assignees: ${teammateList}.`, + `from the start. Available assignees: ${teammateList}. Returns the new objective ` + + `with its generated id.`, inputSchema: { type: 'object', properties: { @@ -704,7 +732,7 @@ function buildAuthorityTools(briefing: BriefingResponse): Tool[] { type: 'array', items: { type: 'string' }, description: - 'Optional list of file paths to attach to the objective. Every thread member (originator, assignee, watchers, directors) receives automatic read access. Use `fs_write` to upload a file first.', + "Optional list of file paths to attach to the objective. Each is mirrored into the objective's namespace at `/objectives//` so the file lives with the objective rather than in your home; every thread member (originator, assignee, watchers, directors) gets read/write access via the namespace ACL. Use `fs_write` to upload a file first.", }, }, required: ['title', 'outcome', 'assignee'], @@ -722,7 +750,7 @@ function buildAuthorityTools(briefing: BriefingResponse): Tool[] { `shifted, the problem went away, the assignee is overwhelmed, etc. Cancellation is ` + `terminal: a cancelled objective cannot be resumed (create a fresh one if you change ` + `your mind). ${cancelScope} Include a \`reason\` so the assignee and any watchers ` + - `understand why.`, + `understand why. Returns the now-cancelled objective.`, inputSchema: { type: 'object', properties: { @@ -748,7 +776,8 @@ function buildAuthorityTools(briefing: BriefingResponse): Tool[] { `lifecycle event and every discussion post on the objective — use this to loop in a ` + `reviewer, a subject-matter expert, or anyone who should have awareness without ` + `being the assignee. Directors are implicit members and never need to be added. ` + - `${watchersScope} Pass \`add\` and/or \`remove\` as arrays of names.`, + `${watchersScope} Pass \`add\` and/or \`remove\` as arrays of names. Returns the ` + + `updated objective with its new watcher list.`, inputSchema: { type: 'object', properties: { @@ -777,7 +806,7 @@ function buildAuthorityTools(briefing: BriefingResponse): Tool[] { `new assignee receive channel pushes — the previous one so they know the ` + `objective left their plate, the new one so they know they now own it. Use this ` + `when the initial assignee is overwhelmed, the wrong skill match, or unavailable. ` + - `Director-only: managers cannot reassign.`, + `Returns the reassigned objective.`, inputSchema: { type: 'object', properties: { @@ -913,12 +942,10 @@ async function handleBroadcast( if (!body) return errorResult('broadcast: `body` is required'); const levelResult = parseLevel(args.level); if (levelResult.error) return errorResult(`broadcast: ${levelResult.error}`); - const title = typeof args.title === 'string' ? args.title : null; const attachments = await resolveAttachmentPaths(args.attachments, brokerClient); if ('error' in attachments) return errorResult(`broadcast: ${attachments.error}`); const result = await brokerClient.push({ body, - title, level: levelResult.level, ...(attachments.list.length > 0 ? { attachments: attachments.list } : {}), }); @@ -939,13 +966,11 @@ async function handleSend( if (!to || !body) return errorResult('send: `to` and `body` are required'); const levelResult = parseLevel(args.level); if (levelResult.error) return errorResult(`send: ${levelResult.error}`); - const title = typeof args.title === 'string' ? args.title : null; const attachments = await resolveAttachmentPaths(args.attachments, brokerClient); if ('error' in attachments) return errorResult(`send: ${attachments.error}`); const result = await brokerClient.push({ to, body, - title, level: levelResult.level, ...(attachments.list.length > 0 ? { attachments: attachments.list } : {}), }); @@ -1091,7 +1116,6 @@ async function handleChannelsPost( if (!body) return errorResult('channels_post: `body` is required'); const levelResult = parseLevel(args.level); if (levelResult.error) return errorResult(`channels_post: ${levelResult.error}`); - const title = typeof args.title === 'string' ? args.title : null; const attachments = await resolveAttachmentPaths(args.attachments, brokerClient); if ('error' in attachments) return errorResult(`channels_post: ${attachments.error}`); @@ -1122,7 +1146,6 @@ async function handleChannelsPost( const result = await brokerClient.push({ body, - title, level: levelResult.level, data: { thread: `chan:${channelId}` }, ...(attachments.list.length > 0 ? { attachments: attachments.list } : {}), @@ -1238,14 +1261,12 @@ async function handleObjectivesDiscuss( if (!id || !body) { return errorResult('objectives_discuss: both `id` and `body` are required'); } - const title = typeof args.title === 'string' ? args.title : undefined; const attachmentsResult = await resolveAttachmentPaths(args.attachments, brokerClient); if ('error' in attachmentsResult) { return errorResult(`objectives_discuss: ${attachmentsResult.error}`); } const message = await brokerClient.discussObjective(id, { body, - ...(title !== undefined ? { title } : {}), ...(attachmentsResult.list.length > 0 ? { attachments: attachmentsResult.list } : {}), }); const attachmentNote = @@ -1405,7 +1426,7 @@ async function handleTeamGet(brokerClient: BrokerClient): Promise, brokerClient: BrokerClient, ): Promise { - const patch: { name?: string; directive?: string; brief?: string } = {}; + const patch: { name?: string; directive?: string; context?: string } = {}; if (typeof args.name === 'string') patch.name = args.name; if (typeof args.directive === 'string') patch.directive = args.directive; - if (typeof args.brief === 'string') patch.brief = args.brief; + if (typeof args.context === 'string') patch.context = args.context; if (Object.keys(patch).length === 0) { - return errorResult('team_update: pass at least one of name, directive, brief'); + return errorResult('team_update: pass at least one of name, directive, context'); } const team = await brokerClient.updateTeam(patch); return textResult( diff --git a/packages/cli/test/commands/serve.test.ts b/packages/cli/test/commands/serve.test.ts index 2d82c8a..cb947ce 100644 --- a/packages/cli/test/commands/serve.test.ts +++ b/packages/cli/test/commands/serve.test.ts @@ -228,7 +228,7 @@ function seedConfig(dir: string): string { team: { name: 'demo', directive: 'ship', - brief: '', + context: '', permissionPresets: {}, }, members: [ diff --git a/packages/cli/test/runtime/bridge.test.ts b/packages/cli/test/runtime/bridge.test.ts index 08255a4..a9d9000 100644 --- a/packages/cli/test/runtime/bridge.test.ts +++ b/packages/cli/test/runtime/bridge.test.ts @@ -34,7 +34,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { RunnerHandle } from '../../src/runtime/runner.js'; import { startRunner } from '../../src/runtime/runner.js'; import { - FAKE_BROKER_MISSION, FAKE_BROKER_NAME, FAKE_BROKER_TEAM_NAME, FAKE_BROKER_TOKEN, @@ -219,7 +218,11 @@ describeIfBuilt('runner + bridge end-to-end', () => { const broadcast = result.tools.find((t) => t.name === 'broadcast'); expect(broadcast?.description).toContain(FAKE_BROKER_NAME); const roster = result.tools.find((t) => t.name === 'roster'); - expect(roster?.description).toContain(FAKE_BROKER_MISSION); + // Roster description references the team name and the agent's + // own identity, but not the directive — directive is already + // pinned in the briefing's MCP `instructions` field, so embedding + // it into tool descriptions would be duplicative. + expect(roster?.description).toContain(FAKE_BROKER_TEAM_NAME); const listTool = result.tools.find((t) => t.name === 'objectives_list'); expect(listTool?.description).toContain('assigned to you'); @@ -237,7 +240,6 @@ describeIfBuilt('runner + bridge end-to-end', () => { arguments: { to: 'peer-1', body: 'hello from runner/bridge test', - title: 'greetings', level: 'warning', }, }, @@ -250,7 +252,6 @@ describeIfBuilt('runner + bridge end-to-end', () => { const lastPush = broker.pushes[broker.pushes.length - 1]; expect(lastPush?.body).toBe('hello from runner/bridge test'); - expect(lastPush?.title).toBe('greetings'); expect(lastPush?.level).toBe('warning'); }); diff --git a/packages/cli/test/runtime/fake-broker.ts b/packages/cli/test/runtime/fake-broker.ts index 97e8b41..729c255 100644 --- a/packages/cli/test/runtime/fake-broker.ts +++ b/packages/cli/test/runtime/fake-broker.ts @@ -136,7 +136,7 @@ export async function startFakeBroker(): Promise { team: { name: FAKE_BROKER_TEAM_NAME, directive: FAKE_BROKER_MISSION, - brief: '', + context: '', permissionPresets: {}, }, teammates: [ diff --git a/packages/cli/test/runtime/tools.test.ts b/packages/cli/test/runtime/tools.test.ts index 0d67b73..4fa15a9 100644 --- a/packages/cli/test/runtime/tools.test.ts +++ b/packages/cli/test/runtime/tools.test.ts @@ -32,7 +32,7 @@ const BRIEFING: BriefingResponse = { team: { name: 'demo', directive: 'ship', - brief: '', + context: '', permissionPresets: {}, }, teammates: [ diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts index bb24c6a..62d035a 100644 --- a/packages/cli/test/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -65,7 +65,7 @@ describe('runSetupCommand', () => { stores.team.setTeam({ name: 'demo-team', directive: 'ship the payment service', - brief: '', + context: '', }); stores.members.addMember({ name: 'director-1', diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 034193e..af2a237 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -334,7 +334,7 @@ export class Client { * Fetch the team-context briefing for the authenticated member. * * Returns the caller's name, role, permissions, team - * (name/directive/brief/presets), list of teammates, open objectives + * (name/directive/context/presets), list of teammates, open objectives * currently on the caller's plate, and the member's personal * `instructions` string ready for `new Server({instructions})` in * the MCP link. @@ -532,7 +532,7 @@ export class Client { } /** - * Read the current team config (name, directive, brief, presets). + * Read the current team config (name, directive, context, presets). * Authenticated; available to every member. */ async getTeam(): Promise { @@ -542,11 +542,11 @@ export class Client { } /** - * Update one or more team-level fields (name, directive, brief). + * Update one or more team-level fields (name, directive, context). * Requires `team.manage`. Permission presets are managed separately * via `setPreset` / `deletePreset` so the API surface stays narrow. */ - async updateTeam(patch: Partial>): Promise { + async updateTeam(patch: Partial>): Promise { const resp = await this.request(PATHS.team, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -1038,6 +1038,17 @@ export class Client { return FsListResponseSchema.parse(await this.json(resp)).entries; } + /** + * Admin-only flat enumeration of every file in every home, newest + * first. The server gates on `members.manage` and 403s otherwise; + * non-admins should keep using `fsList` per-home and `fsShared` for + * cross-home grants. Returned entries always have `kind === 'file'`. + */ + async fsAll(): Promise { + const resp = await this.request(PATHS.fsAll, { method: 'GET' }); + return FsListResponseSchema.parse(await this.json(resp)).entries; + } + /** * Open a long-lived WebSocket subscription for the caller's member * `name` and yield messages as they arrive. Aborts cleanly when diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts index 90f3c79..f769544 100644 --- a/packages/sdk/src/protocol.ts +++ b/packages/sdk/src/protocol.ts @@ -36,7 +36,7 @@ export const PATHS = { // gate on the permission. The helpers below compose the `:name` // subpaths. members: '/members', - // Team — name, directive, brief, permission presets. `GET /team` is + // Team — name, directive, context, permission presets. `GET /team` is // dual-auth (every authenticated member sees the team they're on). // `PATCH /team` requires `team.manage`. Permission-preset CRUD lives // under `/team/presets` (same gate). Mutations apply immediately to @@ -56,6 +56,7 @@ export const PATHS = { fsRm: '/fs/rm', fsMv: '/fs/mv', fsShared: '/fs/shared', + fsAll: '/fs/all', // Device-code enrollment (RFC 8628-shaped). `enroll` mints a // device_code/user_code pair; `enrollPoll` is the device-side poll; // `enrollPending` lists requests waiting for director approval; diff --git a/packages/sdk/src/schemas.ts b/packages/sdk/src/schemas.ts index ea1d941..a711a6f 100644 --- a/packages/sdk/src/schemas.ts +++ b/packages/sdk/src/schemas.ts @@ -47,7 +47,7 @@ export const RoleSchema = z.object({ export const TeamSchema = z.object({ name: z.string().min(1).max(128), directive: z.string().min(1).max(512), - brief: z.string().max(4096).default(''), + context: z.string().max(4096).default(''), permissionPresets: PermissionPresetsSchema.default({}), }); diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 4649b91..9b37770 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -55,11 +55,16 @@ export function hasPermission(permissions: readonly Permission[], required: Perm * A team is the top-level unit the server controls. One deployment * = one team. The team defines the directive and the context every * member inherits, plus any reusable permission presets. + * + * `context` here is the team-level standing context (the longer + * background paragraph that complements `directive`). Distinct + * from agent conversation context — the latter is per-session and + * lives in the runner; the former is durable team configuration. */ export interface Team { name: string; directive: string; - brief: string; + context: string; /** * Named permission bundles members can reference instead of listing * every leaf permission. Always present (may be empty). Common diff --git a/packages/web-shell/package.json b/packages/web-shell/package.json index cdbbc02..b6cea6b 100644 --- a/packages/web-shell/package.json +++ b/packages/web-shell/package.json @@ -40,7 +40,10 @@ "dependencies": { "@agentc7/sdk": "workspace:*", "@preact/signals": "^2.9.0", + "dompurify": "^3.4.2", + "highlight.js": "^11.11.1", "lucide-preact": "^1.14.0", + "marked": "^18.0.3", "preact": "^10.29.1" }, "devDependencies": { diff --git a/packages/web-shell/src/TeamShell.tsx b/packages/web-shell/src/TeamShell.tsx index e245063..757271c 100644 --- a/packages/web-shell/src/TeamShell.tsx +++ b/packages/web-shell/src/TeamShell.tsx @@ -45,6 +45,7 @@ import { ChannelHeader } from './components/ChannelHeader.js'; import { CommandPalette } from './components/CommandPalette.js'; import { Composer } from './components/Composer.js'; import { DisconnectedBanner } from './components/DisconnectedBanner.js'; +import { FilePreviewModal } from './components/FilePreviewModal.js'; import { FilesPanel } from './components/FilesPanel.js'; import { Header } from './components/Header.js'; import { InboxPanel } from './components/InboxPanel.js'; @@ -71,7 +72,7 @@ import { import { type Identity, setIdentity } from './lib/identity.js'; import { closeInspector } from './lib/inspector.js'; import { startSubscribe, streamConnected } from './lib/live.js'; -import { appendMessages, dmOther, messagesByThread } from './lib/messages.js'; +import { appendMessages, dmOther, messagesByThread, objectiveThreadKey } from './lib/messages.js'; import { loadObjectives } from './lib/objectives.js'; import { closePalette, togglePalette } from './lib/palette.js'; import { initializePushState } from './lib/push.js'; @@ -247,14 +248,21 @@ export function TeamShell(props: TeamShellProps): JSX.Element { // Auto-read the active thread: any time the view changes or a // new message lands, bump lastRead for the active thread so its // unread count stays at 0 while the viewer is watching it. + // Objective threads (`obj:`) don't have a top-level URL — + // they surface inside the objective detail view, so when that + // view is active the embedded thread is what the viewer is + // looking at and counts as read. disposeAutoRead = effect(() => { const current = view.value; const map = messagesByThread.value; - if (current.kind !== 'thread') return; - const messages = map.get(current.key) ?? []; + let key: string | null = null; + if (current.kind === 'thread') key = current.key; + else if (current.kind === 'objective-detail') key = objectiveThreadKey(current.id); + if (key === null) return; + const messages = map.get(key) ?? []; if (messages.length === 0) return; const latest = messages[messages.length - 1]; - if (latest) markThreadRead(current.key, latest.ts); + if (latest) markThreadRead(key, latest.ts); }); // Presence-freshness hook: every time the live stream transitions @@ -328,6 +336,7 @@ export function TeamShell(props: TeamShellProps): JSX.Element { )} + ); diff --git a/packages/web-shell/src/components/ActivityInspector.tsx b/packages/web-shell/src/components/ActivityInspector.tsx index ed56e91..137eb9e 100644 --- a/packages/web-shell/src/components/ActivityInspector.tsx +++ b/packages/web-shell/src/components/ActivityInspector.tsx @@ -143,7 +143,7 @@ export function ActivityInspector({ agentName }: ActivityInspectorProps) { class="activity-inspector flex flex-col flex-shrink-0" aria-label={`Activity for ${agentName}`} data-inspector-open={open ? 'true' : 'false'} - style={`--activity-inspector-width:${width}px;height:100%;background:var(--paper);border-left:1px solid var(--rule);position:relative`} + style={`--activity-inspector-width:${width}px`} > {/* biome-ignore lint/a11y/useSemanticElements:
is a thematic break, not a draggable splitter — `role="separator"` with diff --git a/packages/web-shell/src/components/AgentTimeline.tsx b/packages/web-shell/src/components/AgentTimeline.tsx index b085ea7..063c748 100644 --- a/packages/web-shell/src/components/AgentTimeline.tsx +++ b/packages/web-shell/src/components/AgentTimeline.tsx @@ -51,7 +51,7 @@ const DEFAULT_FILTERS: KindFilter = { objective_open: true, objective_close: true, llm_exchange: true, - opaque_http: true, + opaque_http: false, }; const kindFilters = signal({ ...DEFAULT_FILTERS }); diff --git a/packages/web-shell/src/components/Composer.tsx b/packages/web-shell/src/components/Composer.tsx index 910fedd..6e51cf6 100644 --- a/packages/web-shell/src/components/Composer.tsx +++ b/packages/web-shell/src/components/Composer.tsx @@ -265,10 +265,16 @@ export function Composer({ viewer }: ComposerProps) { }; const onKeyDown = (event: JSX.TargetedKeyboardEvent) => { - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault(); - void send(); + if (event.key !== 'Enter' || event.shiftKey) return; + // On touch devices, the soft keyboard's "return" key should insert + // a newline (the native textarea behavior). Sending is the dedicated + // inline button on mobile, where Enter-to-send is unintuitive and + // an easy way to fire off half-typed thoughts. + if (typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches) { + return; } + event.preventDefault(); + void send(); }; const onInput = (event: JSX.TargetedInputEvent) => { diff --git a/packages/web-shell/src/components/DisconnectedBanner.tsx b/packages/web-shell/src/components/DisconnectedBanner.tsx index eaf0f40..dd61d2d 100644 --- a/packages/web-shell/src/components/DisconnectedBanner.tsx +++ b/packages/web-shell/src/components/DisconnectedBanner.tsx @@ -8,12 +8,21 @@ * State machine: * - Healthy steady state → no toast * - Connected, then dropped → "Disconnected" sticky toast + * (after a short grace window, + * so backgrounded-tab churn + * on focus return doesn't + * flash the warning) * - Reconnected with backfill loss → "Reconnected" auto-dismiss toast * * The pre-first-open race (signal starts `false` until the WebSocket's * first `open` event) is gated behind `streamEverConnected` so the * shell doesn't shout "disconnected" during the normal connect window. * + * The post-resume race (a previously-connected tab returning from the + * background briefly reads `connected: false` until the WebSocket + * re-opens) is gated by `DISCONNECT_GRACE_MS`. If reconnect lands + * within that window we never raise the toast at all. + * * Dedup is handled by the `stream-status` tag — re-emitting replaces * any previous stream-status toast in place. Healthy reconnect with * no backfill loss explicitly clears the tag. @@ -25,6 +34,13 @@ import { dismissToastsByTag, toast } from '../lib/toast.js'; const STATUS_TAG = 'stream-status'; +/** + * How long to wait after a disconnect before raising the toast. Tuned + * to absorb the typical tab-resume reconnect window (often <1 s) and + * brief network blips, without making real outages feel silent. + */ +const DISCONNECT_GRACE_MS = 3000; + export function DisconnectedBanner() { // Read all three signals so the effect re-runs on any change. const connected = streamConnected.value; @@ -36,13 +52,19 @@ export function DisconnectedBanner() { if (!connected && !everConnected) return; if (!connected) { - toast.warn({ - tag: STATUS_TAG, - title: 'Disconnected', - body: 'The live update stream is offline — trying to reconnect…', - duration: null, - }); - return; + // Defer the toast — if the stream reconnects within the grace + // window, the cleanup below clears the timer and the user sees + // nothing. This swallows the focus-return flash on browsers + // that pause WebSockets in backgrounded tabs. + const timer = setTimeout(() => { + toast.warn({ + tag: STATUS_TAG, + title: 'Disconnected', + body: 'The live update stream is offline — trying to reconnect…', + duration: null, + }); + }, DISCONNECT_GRACE_MS); + return () => clearTimeout(timer); } // Reconnected, but backfill failed — surface a transient warning, diff --git a/packages/web-shell/src/components/FilePreviewModal.tsx b/packages/web-shell/src/components/FilePreviewModal.tsx new file mode 100644 index 0000000..b4932f5 --- /dev/null +++ b/packages/web-shell/src/components/FilePreviewModal.tsx @@ -0,0 +1,177 @@ +/** + * FilePreviewModal — single global preview surface, mounted at the + * shell level. Reads `currentPreview` from `lib/file-preview.ts`; + * any component that wants to open a preview just calls + * `openPreview(file)` and the modal materializes here. + * + * Inline-handles the cheap native renderers (image / pdf / audio / + * video) since they're a single tag each and there's no benefit to + * code-splitting them. Text/Markdown/Code are pulled in via `lazy()` + * + `Suspense` so the bundle cost only lands when an operator + * actually opens that kind of file. + * + * The modal frame reuses `RouteModal` for backdrop / close / Escape + * handling so we get the same dismissal UX as everything else in + * the shell (Account, Team Settings, etc.). + */ + +import { FS_PATHS } from '@agentc7/sdk/protocol'; +import { lazy, Suspense } from 'preact/compat'; +import { useMemo } from 'preact/hooks'; +import { + closePreview, + currentPreview, + type PreviewableFile, + type RendererSelection, + SIZE_CAPS, + selectRenderer, +} from '../lib/file-preview.js'; +import { Download } from './icons/index.js'; +import { TextPreview } from './preview/TextPreview.js'; +import { RouteModal } from './RouteModal.js'; + +// Lazy chunks — only fetched when the operator opens a preview of +// the matching kind. The Markdown chunk pulls `marked` + `dompurify` +// (~30KB gz); the Code chunk pulls `highlight.js/lib/common` +// (~30KB gz). Each chunk loads exactly once per session. +const MarkdownPreviewLazy = lazy(() => + import('./preview/MarkdownPreview.js').then((m) => ({ default: m.MarkdownPreview })), +); +const CodePreviewLazy = lazy(() => + import('./preview/CodePreview.js').then((m) => ({ default: m.CodePreview })), +); + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +export function FilePreviewModal() { + const file = currentPreview.value; + if (file === null) return null; + return ; +} + +function PreviewModalInner({ file }: { file: PreviewableFile }) { + // Recompute selection when the file changes. Cheap; memoized so + // re-renders from unrelated signals don't re-key the inner + // components. + const selection = useMemo(() => selectRenderer(file), [file.path, file.mimeType, file.size]); + + return ( + +
+
+
+

+ {file.name} +

+

+ {file.mimeType || 'unknown'} · {formatSize(file.size)} +

+
+ + +
+
+ +
+
+
+ ); +} + +function PreviewBody({ file, selection }: { file: PreviewableFile; selection: RendererSelection }) { + switch (selection.kind) { + case 'image': + return ( +
+ {file.name} +
+ ); + case 'pdf': + return ( +