@ours.network/cowork 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/cli.js +232 -250
- package/dist/daemon.js +1107 -1120
- package/dist/web/assets/app.js +1 -1
- package/docs/01-prerequisites.md +1 -1
- package/docs/02-installation.md +1 -1
- package/docs/03-configuration.md +1 -4
- package/docs/05-room-workflow.md +2 -2
- package/docs/06-invites.md +13 -1
- package/docs/07-messaging-history.md +7 -0
- package/docs/08-backup-restore.md +2 -2
- package/docs/10-limitations.md +1 -1
- package/docs/11-web-console.md +2 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -7,15 +7,15 @@ ours-cowork web
|
|
|
7
7
|
ours-cowork docs
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
`ours-cowork web` starts the daemon if it is absent, waits for the console, and opens `http://127.0.0.1:3052/`. Create a room with a
|
|
10
|
+
`ours-cowork web` starts the daemon if it is absent, waits for the console, and opens `http://127.0.0.1:3052/`. Create a room with a name first, then add each invitation requirement from its Invite panel. Names are trimmed, normalized to Unicode NFC, and may contain 1–64 Unicode characters excluding control and format characters. A normalized name must be unique among identities in the shared daemon. The Communication view contains the human-readable room chat; operational records remain in Events and the complete ordered stream remains in Archive.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Each room identity is exactly `ours-cowork:<room name>`, preserving the normalized name supplied at creation so Messenger can display it directly without separate `room_name` metadata. The authenticated identity name is frozen at creation; later room settings may change `room_name` but do not rename the identity, CID, contacts, or history. Identity CIDs, not names, remain the authorization and routing keys. Because identity names are daemon-global, creating another room with the same normalized name fails closed. Earlier unreleased ID- and slug-based identity formats are unsupported; no migration is provided for this unreleased major.
|
|
13
13
|
|
|
14
14
|
The localhost HTTP console has no authentication. Keep it bound to `127.0.0.1`; do not proxy, forward, or expose the port to other hosts. Room state is refreshed by periodic polling, not pushed to the browser.
|
|
15
15
|
|
|
16
16
|
The same listener describes its own room management REST API: `http://127.0.0.1:3052/openapi.json` is the OpenAPI 3.1 document and `http://127.0.0.1:3052/docs` is the browser UI for it. Both are read-only, load no remote assets, and follow the console's loopback-only exposure rules.
|
|
17
17
|
|
|
18
|
-
Start or install the shared daemon with `@ours.network/cli` 2.0
|
|
18
|
+
Start or install the shared daemon with `@ours.network/cli` 2.5.0 before starting cowork. Cowork never embeds, starts, stops, or silently substitutes an ours daemon. It uses the SDK-standard shared selection (the default `~/.ours` daemon, `OURS_CONFIG`, or a coherent `OURS_PORT` plus `OURS_STATE_DIR` selection) and fails clearly when that daemon is unavailable or mismatched.
|
|
19
19
|
|
|
20
20
|
The shared daemon retains application payload history outside its protocol packets: each identity has a `history.sqlite3` database and immutable content-addressed file blobs. Cowork authorizes unread metadata by authenticated CID, reads the corresponding persistent history or blob, durably archives the room item and its complete fan-out, and only then advances that exact SDK unread item. There is no packet-inbox fallback, defer queue, host outbox, or cross-store transaction.
|
|
21
21
|
|
package/dist/cli.js
CHANGED
|
@@ -4061,6 +4061,232 @@ var coerce = {
|
|
|
4061
4061
|
};
|
|
4062
4062
|
var NEVER = INVALID;
|
|
4063
4063
|
|
|
4064
|
+
// src/config.ts
|
|
4065
|
+
var DIRECTORY_MODE = 448;
|
|
4066
|
+
var FILE_MODE = 384;
|
|
4067
|
+
var NO_FOLLOW = nodeFs.constants.O_NOFOLLOW ?? 0;
|
|
4068
|
+
var CoworkConfigSchema = external_exports.object({
|
|
4069
|
+
version: external_exports.literal(1),
|
|
4070
|
+
stateDir: external_exports.string().min(1),
|
|
4071
|
+
rest: external_exports.object({
|
|
4072
|
+
enabled: external_exports.boolean(),
|
|
4073
|
+
port: external_exports.number().int().min(1).max(65535)
|
|
4074
|
+
}).strict()
|
|
4075
|
+
}).strict();
|
|
4076
|
+
var CoworkConfigError = class extends Error {
|
|
4077
|
+
constructor(message, options) {
|
|
4078
|
+
super(message, options);
|
|
4079
|
+
this.name = "CoworkConfigError";
|
|
4080
|
+
}
|
|
4081
|
+
};
|
|
4082
|
+
function defaultConfig(home = homedir()) {
|
|
4083
|
+
return {
|
|
4084
|
+
version: 1,
|
|
4085
|
+
stateDir: resolve(home, ".ours-cowork"),
|
|
4086
|
+
rest: { enabled: true, port: 3052 }
|
|
4087
|
+
};
|
|
4088
|
+
}
|
|
4089
|
+
function loadConfig(env = process.env, io = {}) {
|
|
4090
|
+
rejectRemovedEnvironment(env);
|
|
4091
|
+
const fs2 = io.fs ?? nodeFs;
|
|
4092
|
+
const defaults = defaultConfig(io.home);
|
|
4093
|
+
const configPath = resolve(env.OURS_COWORK_CONFIG ?? join(io.home ?? homedir(), ".ours-cowork", "config.json"));
|
|
4094
|
+
let file = defaults;
|
|
4095
|
+
const stat = lstatIfPresent(fs2, configPath);
|
|
4096
|
+
if (stat) {
|
|
4097
|
+
assertSecureFile(fs2, configPath, "config file");
|
|
4098
|
+
let parsed;
|
|
4099
|
+
try {
|
|
4100
|
+
parsed = JSON.parse(readSecureFile(fs2, configPath, "config file").toString("utf8"));
|
|
4101
|
+
} catch (error) {
|
|
4102
|
+
throw new CoworkConfigError(`malformed cowork config at ${configPath}`, { cause: error });
|
|
4103
|
+
}
|
|
4104
|
+
rejectRemovedConfig(parsed, configPath);
|
|
4105
|
+
try {
|
|
4106
|
+
file = CoworkConfigSchema.parse(parsed);
|
|
4107
|
+
} catch (error) {
|
|
4108
|
+
throw new CoworkConfigError(`invalid cowork config at ${configPath}`, { cause: error });
|
|
4109
|
+
}
|
|
4110
|
+
} else if (env.OURS_COWORK_CONFIG !== void 0) {
|
|
4111
|
+
throw new CoworkConfigError(`configured cowork config does not exist: ${configPath}`);
|
|
4112
|
+
}
|
|
4113
|
+
const restPort = env.OURS_COWORK_REST_PORT === void 0 ? void 0 : parsePort(env.OURS_COWORK_REST_PORT);
|
|
4114
|
+
try {
|
|
4115
|
+
return CoworkConfigSchema.parse({
|
|
4116
|
+
version: 1,
|
|
4117
|
+
stateDir: resolve(env.OURS_COWORK_STATE_DIR ?? file.stateDir),
|
|
4118
|
+
rest: {
|
|
4119
|
+
enabled: restPort === void 0 ? file.rest.enabled : true,
|
|
4120
|
+
port: restPort ?? file.rest.port
|
|
4121
|
+
}
|
|
4122
|
+
});
|
|
4123
|
+
} catch (error) {
|
|
4124
|
+
throw new CoworkConfigError("invalid effective cowork config", { cause: error });
|
|
4125
|
+
}
|
|
4126
|
+
}
|
|
4127
|
+
function rejectRemovedEnvironment(env) {
|
|
4128
|
+
const removed = [
|
|
4129
|
+
"OURS_COWORK_BROKER_URL",
|
|
4130
|
+
"OURS_COWORK_DAEMON_MODE",
|
|
4131
|
+
"OURS_COWORK_DAEMON_ENDPOINT",
|
|
4132
|
+
"OURS_COWORK_DAEMON_STATE_DIR"
|
|
4133
|
+
].filter((name) => env[name] !== void 0);
|
|
4134
|
+
if (removed.length === 0) return;
|
|
4135
|
+
throw new CoworkConfigError(
|
|
4136
|
+
`${removed.join(", ")} ${removed.length === 1 ? "was" : "were"} removed: ours-cowork now attaches only to the shared ours daemon. Configure that daemon with @ours.network/cli and select it through the standard OURS_CONFIG, OURS_PORT, and OURS_STATE_DIR inputs.`
|
|
4137
|
+
);
|
|
4138
|
+
}
|
|
4139
|
+
function rejectRemovedConfig(value, path) {
|
|
4140
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return;
|
|
4141
|
+
const removed = ["brokerUrl", "daemon"].filter((key) => Object.hasOwn(value, key));
|
|
4142
|
+
if (removed.length === 0) return;
|
|
4143
|
+
throw new CoworkConfigError(
|
|
4144
|
+
`cowork config at ${path} contains removed ${removed.join(" and ")} ${removed.length === 1 ? "key" : "keys"}: ours-cowork now attaches only to the shared ours daemon. Remove those keys and configure the daemon with @ours.network/cli.`
|
|
4145
|
+
);
|
|
4146
|
+
}
|
|
4147
|
+
function ensureRuntimeState(config, io = {}) {
|
|
4148
|
+
const parsed = CoworkConfigSchema.parse(config);
|
|
4149
|
+
const fs2 = io.fs ?? nodeFs;
|
|
4150
|
+
const stateDir = resolve(parsed.stateDir);
|
|
4151
|
+
assertSecureAncestors(fs2, stateDir, "state directory");
|
|
4152
|
+
const existing = lstatIfPresent(fs2, stateDir);
|
|
4153
|
+
if (!existing) {
|
|
4154
|
+
createSecureDirectoryTree(fs2, stateDir, "state directory");
|
|
4155
|
+
}
|
|
4156
|
+
assertSecureDirectory(fs2, stateDir, "state directory");
|
|
4157
|
+
const roomsPath = join(stateDir, "rooms");
|
|
4158
|
+
const rooms = lstatIfPresent(fs2, roomsPath);
|
|
4159
|
+
if (!rooms) {
|
|
4160
|
+
fs2.mkdirSync(roomsPath, { mode: DIRECTORY_MODE });
|
|
4161
|
+
secureOpenedDirectory(fs2, roomsPath, "rooms directory");
|
|
4162
|
+
fsyncDirectory(fs2, stateDir);
|
|
4163
|
+
}
|
|
4164
|
+
assertSecureDirectory(fs2, roomsPath, "rooms directory");
|
|
4165
|
+
return {
|
|
4166
|
+
socketPath: join(stateDir, "management.sock"),
|
|
4167
|
+
pidPath: join(stateDir, "daemon.pid"),
|
|
4168
|
+
lockPath: join(stateDir, "daemon.lock")
|
|
4169
|
+
};
|
|
4170
|
+
}
|
|
4171
|
+
function parsePort(value) {
|
|
4172
|
+
if (!/^[1-9][0-9]{0,4}$/.test(value)) {
|
|
4173
|
+
throw new CoworkConfigError("OURS_COWORK_REST_PORT must be a decimal port from 1 to 65535");
|
|
4174
|
+
}
|
|
4175
|
+
const port = Number(value);
|
|
4176
|
+
if (port > 65535) throw new CoworkConfigError("OURS_COWORK_REST_PORT must be from 1 to 65535");
|
|
4177
|
+
return port;
|
|
4178
|
+
}
|
|
4179
|
+
function assertSecureAncestors(fs2, path, label) {
|
|
4180
|
+
const absolute = isAbsolute(path) ? path : resolve(path);
|
|
4181
|
+
const root = parse(absolute).root;
|
|
4182
|
+
const rootOwner = fs2.lstatSync(root).uid;
|
|
4183
|
+
let cursor = root;
|
|
4184
|
+
const components = absolute.slice(root.length).split("/").filter(Boolean);
|
|
4185
|
+
for (const [index, component] of components.entries()) {
|
|
4186
|
+
cursor = join(cursor, component);
|
|
4187
|
+
const stat = lstatIfPresent(fs2, cursor);
|
|
4188
|
+
if (stat?.isSymbolicLink()) throw new CoworkConfigError(`${label} must not traverse a symbolic link (symlink): ${cursor}`);
|
|
4189
|
+
if (!stat) break;
|
|
4190
|
+
if (!stat.isDirectory()) {
|
|
4191
|
+
if (index === components.length - 1) return;
|
|
4192
|
+
throw new CoworkConfigError(`${label} ancestor is not a directory: ${cursor}`);
|
|
4193
|
+
}
|
|
4194
|
+
if (index < components.length - 1) assertTrustedAncestor(stat, rootOwner, cursor, label);
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
function assertTrustedAncestor(stat, rootOwner, path, label) {
|
|
4198
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : stat.uid;
|
|
4199
|
+
const trustedStickyDirectory = (stat.mode & 512) !== 0;
|
|
4200
|
+
const writableByOthers = (stat.mode & 18) !== 0;
|
|
4201
|
+
const trustedOwner = stat.uid === uid || stat.uid === 0 || stat.uid === rootOwner;
|
|
4202
|
+
if (writableByOthers && (!trustedStickyDirectory || !trustedOwner)) {
|
|
4203
|
+
throw new CoworkConfigError(`${label} has an unsafe writable ancestor: ${path}`);
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
function createSecureDirectoryTree(fs2, path, label) {
|
|
4207
|
+
const missing = [];
|
|
4208
|
+
let cursor = path;
|
|
4209
|
+
while (!lstatIfPresent(fs2, cursor)) {
|
|
4210
|
+
missing.push(cursor);
|
|
4211
|
+
const parent = dirname(cursor);
|
|
4212
|
+
if (parent === cursor) throw new CoworkConfigError(`cannot locate an existing ancestor for ${label}`);
|
|
4213
|
+
cursor = parent;
|
|
4214
|
+
}
|
|
4215
|
+
assertSecureAncestors(fs2, path, label);
|
|
4216
|
+
for (const directory of missing.reverse()) {
|
|
4217
|
+
fs2.mkdirSync(directory, { mode: DIRECTORY_MODE });
|
|
4218
|
+
secureOpenedDirectory(fs2, directory, label);
|
|
4219
|
+
fsyncDirectory(fs2, dirname(directory));
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
function secureOpenedDirectory(fs2, path, label) {
|
|
4223
|
+
let fd;
|
|
4224
|
+
try {
|
|
4225
|
+
fd = fs2.openSync(path, nodeFs.constants.O_RDONLY | NO_FOLLOW);
|
|
4226
|
+
const opened = fs2.fstatSync(fd);
|
|
4227
|
+
const current = fs2.lstatSync(path);
|
|
4228
|
+
if (!opened.isDirectory() || current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) {
|
|
4229
|
+
throw new CoworkConfigError(`${label} changed while opening`);
|
|
4230
|
+
}
|
|
4231
|
+
fs2.fchmodSync(fd, DIRECTORY_MODE);
|
|
4232
|
+
fs2.fsyncSync(fd);
|
|
4233
|
+
} finally {
|
|
4234
|
+
if (fd !== void 0) fs2.closeSync(fd);
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
function assertSecureDirectory(fs2, path, label) {
|
|
4238
|
+
const stat = fs2.lstatSync(path);
|
|
4239
|
+
if (stat.isSymbolicLink()) throw new CoworkConfigError(`${label} must not be a symbolic link (symlink)`);
|
|
4240
|
+
if (!stat.isDirectory()) throw new CoworkConfigError(`${label} must be a directory`);
|
|
4241
|
+
if ((stat.mode & 511) !== DIRECTORY_MODE) {
|
|
4242
|
+
throw new CoworkConfigError(`${label} mode must be 0700`);
|
|
4243
|
+
}
|
|
4244
|
+
assertOwner(stat, label);
|
|
4245
|
+
}
|
|
4246
|
+
function assertSecureFile(fs2, path, label) {
|
|
4247
|
+
const stat = fs2.lstatSync(path);
|
|
4248
|
+
if (stat.isSymbolicLink()) throw new CoworkConfigError(`${label} must not be a symbolic link (symlink)`);
|
|
4249
|
+
if (!stat.isFile() || stat.nlink !== 1) throw new CoworkConfigError(`${label} must be a single-link regular file`);
|
|
4250
|
+
if ((stat.mode & 511) !== FILE_MODE) throw new CoworkConfigError(`${label} mode must be 0600`);
|
|
4251
|
+
assertOwner(stat, label);
|
|
4252
|
+
}
|
|
4253
|
+
function assertOwner(stat, label) {
|
|
4254
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
|
4255
|
+
throw new CoworkConfigError(`${label} must be owned by the current user`);
|
|
4256
|
+
}
|
|
4257
|
+
}
|
|
4258
|
+
function readSecureFile(fs2, path, label) {
|
|
4259
|
+
let fd;
|
|
4260
|
+
try {
|
|
4261
|
+
fd = fs2.openSync(path, nodeFs.constants.O_RDONLY | NO_FOLLOW);
|
|
4262
|
+
const opened = fs2.fstatSync(fd);
|
|
4263
|
+
const current = fs2.lstatSync(path);
|
|
4264
|
+
if (!opened.isFile() || opened.nlink !== 1 || (opened.mode & 511) !== FILE_MODE || typeof process.getuid === "function" && opened.uid !== process.getuid() || current.isSymbolicLink() || current.dev !== opened.dev || current.ino !== opened.ino) {
|
|
4265
|
+
throw new CoworkConfigError(`${label} changed while opening`);
|
|
4266
|
+
}
|
|
4267
|
+
return fs2.readFileSync(fd);
|
|
4268
|
+
} finally {
|
|
4269
|
+
if (fd !== void 0) fs2.closeSync(fd);
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
4272
|
+
function fsyncDirectory(fs2, path) {
|
|
4273
|
+
let fd;
|
|
4274
|
+
try {
|
|
4275
|
+
fd = fs2.openSync(path, nodeFs.constants.O_RDONLY | NO_FOLLOW);
|
|
4276
|
+
fs2.fsyncSync(fd);
|
|
4277
|
+
} finally {
|
|
4278
|
+
if (fd !== void 0) fs2.closeSync(fd);
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
function lstatIfPresent(fs2, path) {
|
|
4282
|
+
try {
|
|
4283
|
+
return fs2.lstatSync(path);
|
|
4284
|
+
} catch (error) {
|
|
4285
|
+
if (error.code === "ENOENT") return void 0;
|
|
4286
|
+
throw error;
|
|
4287
|
+
}
|
|
4288
|
+
}
|
|
4289
|
+
|
|
4064
4290
|
// src/contracts.ts
|
|
4065
4291
|
import { createHash } from "node:crypto";
|
|
4066
4292
|
var MAX_TEXT_BYTES = 262144;
|
|
@@ -4072,8 +4298,6 @@ var MAX_FILE_NAME_BYTES = 255;
|
|
|
4072
4298
|
var MAX_MIME_BYTES = 255;
|
|
4073
4299
|
var MAX_ROLE_BYTES = 256;
|
|
4074
4300
|
var MAX_ROOM_NAME_CHARACTERS = 64;
|
|
4075
|
-
var MAX_ROOM_IDENTITY_NAME_CHARACTERS = 64;
|
|
4076
|
-
var MAX_FRIENDLY_IDENTITY_SLUG_CHARACTERS = 25;
|
|
4077
4301
|
function utf8Bounded(label, maximumBytes) {
|
|
4078
4302
|
return external_exports.string().refine((value) => Buffer.byteLength(value, "utf8") >= 1, `${label} must be at least 1 UTF-8 byte`).refine(
|
|
4079
4303
|
(value) => Buffer.byteLength(value, "utf8") <= maximumBytes,
|
|
@@ -4120,24 +4344,13 @@ var RoomNameSchema = external_exports.string().refine(
|
|
|
4120
4344
|
});
|
|
4121
4345
|
}
|
|
4122
4346
|
});
|
|
4123
|
-
var
|
|
4124
|
-
var ROOM_IDENTITY_PREFIX = "ours-cowork-";
|
|
4125
|
-
var SDK_IDENTITY_NAME_PATTERN = /^[A-Za-z0-9 _.@-]{1,64}$/;
|
|
4126
|
-
var FRIENDLY_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
4127
|
-
function roomIdentityName(roomId) {
|
|
4128
|
-
return `${ROOM_IDENTITY_PREFIX}${LowerCrockfordUlidSchema.parse(roomId)}`;
|
|
4129
|
-
}
|
|
4130
|
-
function legacyRoomIdentityName(roomId) {
|
|
4131
|
-
return `cowork-room-${LowerCrockfordUlidSchema.parse(roomId)}`;
|
|
4132
|
-
}
|
|
4347
|
+
var ROOM_IDENTITY_PREFIX = "ours-cowork:";
|
|
4133
4348
|
function isPersistedRoomIdentityName(roomId, identityName) {
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
const
|
|
4138
|
-
|
|
4139
|
-
const slug = identityName.slice(ROOM_IDENTITY_PREFIX.length, -suffix.length);
|
|
4140
|
-
return slug.length >= 1 && slug.length <= MAX_FRIENDLY_IDENTITY_SLUG_CHARACTERS && FRIENDLY_SLUG_PATTERN.test(slug) && identityName.length <= MAX_ROOM_IDENTITY_NAME_CHARACTERS && SDK_IDENTITY_NAME_PATTERN.test(identityName);
|
|
4349
|
+
if (!LowerCrockfordUlidSchema.safeParse(roomId).success || !identityName.startsWith(ROOM_IDENTITY_PREFIX)) {
|
|
4350
|
+
return false;
|
|
4351
|
+
}
|
|
4352
|
+
const parsed = RoomNameSchema.safeParse(identityName.slice(ROOM_IDENTITY_PREFIX.length));
|
|
4353
|
+
return parsed.success && identityName === `${ROOM_IDENTITY_PREFIX}${parsed.data}`;
|
|
4141
4354
|
}
|
|
4142
4355
|
var RoleSchema = utf8Bounded("role", MAX_ROLE_BYTES);
|
|
4143
4356
|
var ROOM_ROLE = "room";
|
|
@@ -4794,237 +5007,6 @@ var AppendRecordSchema = external_exports.discriminatedUnion("kind", [
|
|
|
4794
5007
|
refineFileRecord(record, context);
|
|
4795
5008
|
});
|
|
4796
5009
|
|
|
4797
|
-
// src/config.ts
|
|
4798
|
-
var DIRECTORY_MODE = 448;
|
|
4799
|
-
var FILE_MODE = 384;
|
|
4800
|
-
var NO_FOLLOW = nodeFs.constants.O_NOFOLLOW ?? 0;
|
|
4801
|
-
var CoworkConfigSchema = external_exports.object({
|
|
4802
|
-
version: external_exports.literal(1),
|
|
4803
|
-
stateDir: external_exports.string().min(1),
|
|
4804
|
-
roomIdentity: external_exports.object({
|
|
4805
|
-
nameMode: RoomIdentityNameModeSchema
|
|
4806
|
-
}).strict().default({ nameMode: "stable_id" }),
|
|
4807
|
-
rest: external_exports.object({
|
|
4808
|
-
enabled: external_exports.boolean(),
|
|
4809
|
-
port: external_exports.number().int().min(1).max(65535)
|
|
4810
|
-
}).strict()
|
|
4811
|
-
}).strict();
|
|
4812
|
-
var CoworkConfigError = class extends Error {
|
|
4813
|
-
constructor(message, options) {
|
|
4814
|
-
super(message, options);
|
|
4815
|
-
this.name = "CoworkConfigError";
|
|
4816
|
-
}
|
|
4817
|
-
};
|
|
4818
|
-
function defaultConfig(home = homedir()) {
|
|
4819
|
-
return {
|
|
4820
|
-
version: 1,
|
|
4821
|
-
stateDir: resolve(home, ".ours-cowork"),
|
|
4822
|
-
roomIdentity: { nameMode: "stable_id" },
|
|
4823
|
-
rest: { enabled: true, port: 3052 }
|
|
4824
|
-
};
|
|
4825
|
-
}
|
|
4826
|
-
function loadConfig(env = process.env, io = {}) {
|
|
4827
|
-
rejectRemovedEnvironment(env);
|
|
4828
|
-
const fs2 = io.fs ?? nodeFs;
|
|
4829
|
-
const defaults = defaultConfig(io.home);
|
|
4830
|
-
const configPath = resolve(env.OURS_COWORK_CONFIG ?? join(io.home ?? homedir(), ".ours-cowork", "config.json"));
|
|
4831
|
-
let file = defaults;
|
|
4832
|
-
const stat = lstatIfPresent(fs2, configPath);
|
|
4833
|
-
if (stat) {
|
|
4834
|
-
assertSecureFile(fs2, configPath, "config file");
|
|
4835
|
-
let parsed;
|
|
4836
|
-
try {
|
|
4837
|
-
parsed = JSON.parse(readSecureFile(fs2, configPath, "config file").toString("utf8"));
|
|
4838
|
-
} catch (error) {
|
|
4839
|
-
throw new CoworkConfigError(`malformed cowork config at ${configPath}`, { cause: error });
|
|
4840
|
-
}
|
|
4841
|
-
rejectRemovedConfig(parsed, configPath);
|
|
4842
|
-
try {
|
|
4843
|
-
file = CoworkConfigSchema.parse(parsed);
|
|
4844
|
-
} catch (error) {
|
|
4845
|
-
throw new CoworkConfigError(`invalid cowork config at ${configPath}`, { cause: error });
|
|
4846
|
-
}
|
|
4847
|
-
} else if (env.OURS_COWORK_CONFIG !== void 0) {
|
|
4848
|
-
throw new CoworkConfigError(`configured cowork config does not exist: ${configPath}`);
|
|
4849
|
-
}
|
|
4850
|
-
const restPort = env.OURS_COWORK_REST_PORT === void 0 ? void 0 : parsePort(env.OURS_COWORK_REST_PORT);
|
|
4851
|
-
try {
|
|
4852
|
-
return CoworkConfigSchema.parse({
|
|
4853
|
-
version: 1,
|
|
4854
|
-
stateDir: resolve(env.OURS_COWORK_STATE_DIR ?? file.stateDir),
|
|
4855
|
-
roomIdentity: file.roomIdentity,
|
|
4856
|
-
rest: {
|
|
4857
|
-
enabled: restPort === void 0 ? file.rest.enabled : true,
|
|
4858
|
-
port: restPort ?? file.rest.port
|
|
4859
|
-
}
|
|
4860
|
-
});
|
|
4861
|
-
} catch (error) {
|
|
4862
|
-
throw new CoworkConfigError("invalid effective cowork config", { cause: error });
|
|
4863
|
-
}
|
|
4864
|
-
}
|
|
4865
|
-
function rejectRemovedEnvironment(env) {
|
|
4866
|
-
const removed = [
|
|
4867
|
-
"OURS_COWORK_BROKER_URL",
|
|
4868
|
-
"OURS_COWORK_DAEMON_MODE",
|
|
4869
|
-
"OURS_COWORK_DAEMON_ENDPOINT",
|
|
4870
|
-
"OURS_COWORK_DAEMON_STATE_DIR"
|
|
4871
|
-
].filter((name) => env[name] !== void 0);
|
|
4872
|
-
if (removed.length === 0) return;
|
|
4873
|
-
throw new CoworkConfigError(
|
|
4874
|
-
`${removed.join(", ")} ${removed.length === 1 ? "was" : "were"} removed: ours-cowork now attaches only to the shared ours daemon. Configure that daemon with @ours.network/cli and select it through the standard OURS_CONFIG, OURS_PORT, and OURS_STATE_DIR inputs.`
|
|
4875
|
-
);
|
|
4876
|
-
}
|
|
4877
|
-
function rejectRemovedConfig(value, path) {
|
|
4878
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) return;
|
|
4879
|
-
const removed = ["brokerUrl", "daemon"].filter((key) => Object.hasOwn(value, key));
|
|
4880
|
-
if (removed.length === 0) return;
|
|
4881
|
-
throw new CoworkConfigError(
|
|
4882
|
-
`cowork config at ${path} contains removed ${removed.join(" and ")} ${removed.length === 1 ? "key" : "keys"}: ours-cowork now attaches only to the shared ours daemon. Remove those keys and configure the daemon with @ours.network/cli.`
|
|
4883
|
-
);
|
|
4884
|
-
}
|
|
4885
|
-
function ensureRuntimeState(config, io = {}) {
|
|
4886
|
-
const parsed = CoworkConfigSchema.parse(config);
|
|
4887
|
-
const fs2 = io.fs ?? nodeFs;
|
|
4888
|
-
const stateDir = resolve(parsed.stateDir);
|
|
4889
|
-
assertSecureAncestors(fs2, stateDir, "state directory");
|
|
4890
|
-
const existing = lstatIfPresent(fs2, stateDir);
|
|
4891
|
-
if (!existing) {
|
|
4892
|
-
createSecureDirectoryTree(fs2, stateDir, "state directory");
|
|
4893
|
-
}
|
|
4894
|
-
assertSecureDirectory(fs2, stateDir, "state directory");
|
|
4895
|
-
const roomsPath = join(stateDir, "rooms");
|
|
4896
|
-
const rooms = lstatIfPresent(fs2, roomsPath);
|
|
4897
|
-
if (!rooms) {
|
|
4898
|
-
fs2.mkdirSync(roomsPath, { mode: DIRECTORY_MODE });
|
|
4899
|
-
secureOpenedDirectory(fs2, roomsPath, "rooms directory");
|
|
4900
|
-
fsyncDirectory(fs2, stateDir);
|
|
4901
|
-
}
|
|
4902
|
-
assertSecureDirectory(fs2, roomsPath, "rooms directory");
|
|
4903
|
-
return {
|
|
4904
|
-
socketPath: join(stateDir, "management.sock"),
|
|
4905
|
-
pidPath: join(stateDir, "daemon.pid"),
|
|
4906
|
-
lockPath: join(stateDir, "daemon.lock")
|
|
4907
|
-
};
|
|
4908
|
-
}
|
|
4909
|
-
function parsePort(value) {
|
|
4910
|
-
if (!/^[1-9][0-9]{0,4}$/.test(value)) {
|
|
4911
|
-
throw new CoworkConfigError("OURS_COWORK_REST_PORT must be a decimal port from 1 to 65535");
|
|
4912
|
-
}
|
|
4913
|
-
const port = Number(value);
|
|
4914
|
-
if (port > 65535) throw new CoworkConfigError("OURS_COWORK_REST_PORT must be from 1 to 65535");
|
|
4915
|
-
return port;
|
|
4916
|
-
}
|
|
4917
|
-
function assertSecureAncestors(fs2, path, label) {
|
|
4918
|
-
const absolute = isAbsolute(path) ? path : resolve(path);
|
|
4919
|
-
const root = parse(absolute).root;
|
|
4920
|
-
const rootOwner = fs2.lstatSync(root).uid;
|
|
4921
|
-
let cursor = root;
|
|
4922
|
-
const components = absolute.slice(root.length).split("/").filter(Boolean);
|
|
4923
|
-
for (const [index, component] of components.entries()) {
|
|
4924
|
-
cursor = join(cursor, component);
|
|
4925
|
-
const stat = lstatIfPresent(fs2, cursor);
|
|
4926
|
-
if (stat?.isSymbolicLink()) throw new CoworkConfigError(`${label} must not traverse a symbolic link (symlink): ${cursor}`);
|
|
4927
|
-
if (!stat) break;
|
|
4928
|
-
if (!stat.isDirectory()) {
|
|
4929
|
-
if (index === components.length - 1) return;
|
|
4930
|
-
throw new CoworkConfigError(`${label} ancestor is not a directory: ${cursor}`);
|
|
4931
|
-
}
|
|
4932
|
-
if (index < components.length - 1) assertTrustedAncestor(stat, rootOwner, cursor, label);
|
|
4933
|
-
}
|
|
4934
|
-
}
|
|
4935
|
-
function assertTrustedAncestor(stat, rootOwner, path, label) {
|
|
4936
|
-
const uid = typeof process.getuid === "function" ? process.getuid() : stat.uid;
|
|
4937
|
-
const trustedStickyDirectory = (stat.mode & 512) !== 0;
|
|
4938
|
-
const writableByOthers = (stat.mode & 18) !== 0;
|
|
4939
|
-
const trustedOwner = stat.uid === uid || stat.uid === 0 || stat.uid === rootOwner;
|
|
4940
|
-
if (writableByOthers && (!trustedStickyDirectory || !trustedOwner)) {
|
|
4941
|
-
throw new CoworkConfigError(`${label} has an unsafe writable ancestor: ${path}`);
|
|
4942
|
-
}
|
|
4943
|
-
}
|
|
4944
|
-
function createSecureDirectoryTree(fs2, path, label) {
|
|
4945
|
-
const missing = [];
|
|
4946
|
-
let cursor = path;
|
|
4947
|
-
while (!lstatIfPresent(fs2, cursor)) {
|
|
4948
|
-
missing.push(cursor);
|
|
4949
|
-
const parent = dirname(cursor);
|
|
4950
|
-
if (parent === cursor) throw new CoworkConfigError(`cannot locate an existing ancestor for ${label}`);
|
|
4951
|
-
cursor = parent;
|
|
4952
|
-
}
|
|
4953
|
-
assertSecureAncestors(fs2, path, label);
|
|
4954
|
-
for (const directory of missing.reverse()) {
|
|
4955
|
-
fs2.mkdirSync(directory, { mode: DIRECTORY_MODE });
|
|
4956
|
-
secureOpenedDirectory(fs2, directory, label);
|
|
4957
|
-
fsyncDirectory(fs2, dirname(directory));
|
|
4958
|
-
}
|
|
4959
|
-
}
|
|
4960
|
-
function secureOpenedDirectory(fs2, path, label) {
|
|
4961
|
-
let fd;
|
|
4962
|
-
try {
|
|
4963
|
-
fd = fs2.openSync(path, nodeFs.constants.O_RDONLY | NO_FOLLOW);
|
|
4964
|
-
const opened = fs2.fstatSync(fd);
|
|
4965
|
-
const current = fs2.lstatSync(path);
|
|
4966
|
-
if (!opened.isDirectory() || current.isSymbolicLink() || opened.dev !== current.dev || opened.ino !== current.ino) {
|
|
4967
|
-
throw new CoworkConfigError(`${label} changed while opening`);
|
|
4968
|
-
}
|
|
4969
|
-
fs2.fchmodSync(fd, DIRECTORY_MODE);
|
|
4970
|
-
fs2.fsyncSync(fd);
|
|
4971
|
-
} finally {
|
|
4972
|
-
if (fd !== void 0) fs2.closeSync(fd);
|
|
4973
|
-
}
|
|
4974
|
-
}
|
|
4975
|
-
function assertSecureDirectory(fs2, path, label) {
|
|
4976
|
-
const stat = fs2.lstatSync(path);
|
|
4977
|
-
if (stat.isSymbolicLink()) throw new CoworkConfigError(`${label} must not be a symbolic link (symlink)`);
|
|
4978
|
-
if (!stat.isDirectory()) throw new CoworkConfigError(`${label} must be a directory`);
|
|
4979
|
-
if ((stat.mode & 511) !== DIRECTORY_MODE) {
|
|
4980
|
-
throw new CoworkConfigError(`${label} mode must be 0700`);
|
|
4981
|
-
}
|
|
4982
|
-
assertOwner(stat, label);
|
|
4983
|
-
}
|
|
4984
|
-
function assertSecureFile(fs2, path, label) {
|
|
4985
|
-
const stat = fs2.lstatSync(path);
|
|
4986
|
-
if (stat.isSymbolicLink()) throw new CoworkConfigError(`${label} must not be a symbolic link (symlink)`);
|
|
4987
|
-
if (!stat.isFile() || stat.nlink !== 1) throw new CoworkConfigError(`${label} must be a single-link regular file`);
|
|
4988
|
-
if ((stat.mode & 511) !== FILE_MODE) throw new CoworkConfigError(`${label} mode must be 0600`);
|
|
4989
|
-
assertOwner(stat, label);
|
|
4990
|
-
}
|
|
4991
|
-
function assertOwner(stat, label) {
|
|
4992
|
-
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
|
4993
|
-
throw new CoworkConfigError(`${label} must be owned by the current user`);
|
|
4994
|
-
}
|
|
4995
|
-
}
|
|
4996
|
-
function readSecureFile(fs2, path, label) {
|
|
4997
|
-
let fd;
|
|
4998
|
-
try {
|
|
4999
|
-
fd = fs2.openSync(path, nodeFs.constants.O_RDONLY | NO_FOLLOW);
|
|
5000
|
-
const opened = fs2.fstatSync(fd);
|
|
5001
|
-
const current = fs2.lstatSync(path);
|
|
5002
|
-
if (!opened.isFile() || opened.nlink !== 1 || (opened.mode & 511) !== FILE_MODE || typeof process.getuid === "function" && opened.uid !== process.getuid() || current.isSymbolicLink() || current.dev !== opened.dev || current.ino !== opened.ino) {
|
|
5003
|
-
throw new CoworkConfigError(`${label} changed while opening`);
|
|
5004
|
-
}
|
|
5005
|
-
return fs2.readFileSync(fd);
|
|
5006
|
-
} finally {
|
|
5007
|
-
if (fd !== void 0) fs2.closeSync(fd);
|
|
5008
|
-
}
|
|
5009
|
-
}
|
|
5010
|
-
function fsyncDirectory(fs2, path) {
|
|
5011
|
-
let fd;
|
|
5012
|
-
try {
|
|
5013
|
-
fd = fs2.openSync(path, nodeFs.constants.O_RDONLY | NO_FOLLOW);
|
|
5014
|
-
fs2.fsyncSync(fd);
|
|
5015
|
-
} finally {
|
|
5016
|
-
if (fd !== void 0) fs2.closeSync(fd);
|
|
5017
|
-
}
|
|
5018
|
-
}
|
|
5019
|
-
function lstatIfPresent(fs2, path) {
|
|
5020
|
-
try {
|
|
5021
|
-
return fs2.lstatSync(path);
|
|
5022
|
-
} catch (error) {
|
|
5023
|
-
if (error.code === "ENOENT") return void 0;
|
|
5024
|
-
throw error;
|
|
5025
|
-
}
|
|
5026
|
-
}
|
|
5027
|
-
|
|
5028
5010
|
// src/cli.ts
|
|
5029
5011
|
var EXIT = {
|
|
5030
5012
|
success: 0,
|