From 342d24f2857c20c32fd5bb06362ff561e7e122e2 Mon Sep 17 00:00:00 2001 From: "rayson951005@gmail.com" Date: Fri, 26 Jun 2026 20:33:49 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(v3):=20=E8=B7=A8=E6=9C=BA=E4=B8=8A?= =?UTF-8?q?=E6=89=8B=E9=93=BE=E8=B7=AF=20=E2=80=94=20auth=20issue=20/=20lo?= =?UTF-8?q?gin=20--token=20/=20room=20invite=20/=20join=20=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E6=94=BE=E5=AE=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐审计抓出的跨机 onboarding 大洞(根因:边机 abg auth login 自签的 token 进不了 broker store → broker 验 token 用自己 store → 4401): - `abg auth issue --id --name`(在 broker 机跑):在 broker store registerIdentity + issueToken + **打印 token 带外发**,**不写本地 auth-token**(token 是给别人的) - `abg auth login --token `(在边机跑):把 broker 签发的 token 装进 `/auth-token`(0600),**不 register/issue**;空 token 拒 - `abg auth login --id --name`:legacy 本机自签(单机:同一 store 既签又验),保留不变 - `abg room invite [--name] [--broker-url]`(broker 机):issueToken + addMember + 打印对方该跑的一条龙(`abg auth login --token …` + `abg join …`);**caller 必须是成员**(复用 §11.2 isMember 授权门) - `abg join` 远程房间放宽:本地无此房不再硬失败,只映射 cwd→room;成员制交 broker 订阅时强制(**不授予成员资格**) - 连接卡 net-detect.ts:改用真实命令(引导用 room invite) - cli.ts 路由 auth issue + room invite + printHelp 安全:auth issue/invite 复用 isMember 授权门;join 放宽只映射 cwd、broker 才是权威;0700 目录 + 0600 token;空 token 拒。 bun run check 1860 pass。 feat(v3): cross-machine onboarding — auth issue / login --token / room invite / remote join Broker validates tokens against ITS store, so an edge self-signed token was never valid (4401). Adds broker-side token issuance + edge-side install + one-shot room invite + remote-join relax. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012rhWKm1VUSnEVxmjpYwNfc --- plugins/agentbridge/server/bridge-server.js | 212 ++++++++++---------- plugins/agentbridge/server/daemon.js | 4 +- src/cli.ts | 14 +- src/cli/auth.ts | 157 +++++++++++---- src/cli/room.ts | 131 ++++++++++-- src/net-detect.ts | 20 +- src/unit-test/cli-auth.test.ts | 64 +++++- src/unit-test/cli-room.test.ts | 198 +++++++++++++++++- src/unit-test/net-detect.test.ts | 17 +- 9 files changed, 640 insertions(+), 177 deletions(-) diff --git a/plugins/agentbridge/server/bridge-server.js b/plugins/agentbridge/server/bridge-server.js index ae4455f..14b080a 100755 --- a/plugins/agentbridge/server/bridge-server.js +++ b/plugins/agentbridge/server/bridge-server.js @@ -45,7 +45,7 @@ var __export = (target, all) => { }); }; -// node_modules/ajv/dist/compile/codegen/code.js +// ../../../node_modules/ajv/dist/compile/codegen/code.js var require_code = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.regexpCode = exports.getEsmExportName = exports.getProperty = exports.safeStringify = exports.stringify = exports.strConcat = exports.addCodeArg = exports.str = exports._ = exports.nil = exports._Code = exports.Name = exports.IDENTIFIER = exports._CodeOrName = undefined; @@ -199,7 +199,7 @@ var require_code = __commonJS((exports) => { exports.regexpCode = regexpCode; }); -// node_modules/ajv/dist/compile/codegen/scope.js +// ../../../node_modules/ajv/dist/compile/codegen/scope.js var require_scope = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.ValueScope = exports.ValueScopeName = exports.Scope = exports.varKinds = exports.UsedValueState = undefined; @@ -345,7 +345,7 @@ var require_scope = __commonJS((exports) => { exports.ValueScope = ValueScope; }); -// node_modules/ajv/dist/compile/codegen/index.js +// ../../../node_modules/ajv/dist/compile/codegen/index.js var require_codegen = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.or = exports.and = exports.not = exports.CodeGen = exports.operators = exports.varKinds = exports.ValueScopeName = exports.ValueScope = exports.Scope = exports.Name = exports.regexpCode = exports.stringify = exports.getProperty = exports.nil = exports.strConcat = exports.str = exports._ = undefined; @@ -1055,7 +1055,7 @@ var require_codegen = __commonJS((exports) => { } }); -// node_modules/ajv/dist/compile/util.js +// ../../../node_modules/ajv/dist/compile/util.js var require_util = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.checkStrictMode = exports.getErrorPath = exports.Type = exports.useFunc = exports.setEvaluated = exports.evaluatedPropsToName = exports.mergeEvaluated = exports.eachItem = exports.unescapeJsonPointer = exports.escapeJsonPointer = exports.escapeFragment = exports.unescapeFragment = exports.schemaRefOrVal = exports.schemaHasRulesButRef = exports.schemaHasRules = exports.checkUnknownRules = exports.alwaysValidSchema = exports.toHash = undefined; @@ -1219,7 +1219,7 @@ var require_util = __commonJS((exports) => { exports.checkStrictMode = checkStrictMode; }); -// node_modules/ajv/dist/compile/names.js +// ../../../node_modules/ajv/dist/compile/names.js var require_names = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -1244,7 +1244,7 @@ var require_names = __commonJS((exports) => { exports.default = names; }); -// node_modules/ajv/dist/compile/errors.js +// ../../../node_modules/ajv/dist/compile/errors.js var require_errors = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.extendErrors = exports.resetErrorsCount = exports.reportExtraError = exports.reportError = exports.keyword$DataError = exports.keywordError = undefined; @@ -1362,7 +1362,7 @@ var require_errors = __commonJS((exports) => { } }); -// node_modules/ajv/dist/compile/validate/boolSchema.js +// ../../../node_modules/ajv/dist/compile/validate/boolSchema.js var require_boolSchema = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.boolOrEmptySchema = exports.topBoolOrEmptySchema = undefined; @@ -1410,7 +1410,7 @@ var require_boolSchema = __commonJS((exports) => { } }); -// node_modules/ajv/dist/compile/rules.js +// ../../../node_modules/ajv/dist/compile/rules.js var require_rules = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.getRules = exports.isJSONType = undefined; @@ -1438,7 +1438,7 @@ var require_rules = __commonJS((exports) => { exports.getRules = getRules; }); -// node_modules/ajv/dist/compile/validate/applicability.js +// ../../../node_modules/ajv/dist/compile/validate/applicability.js var require_applicability = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.shouldUseRule = exports.shouldUseGroup = exports.schemaHasRulesForType = undefined; @@ -1458,7 +1458,7 @@ var require_applicability = __commonJS((exports) => { exports.shouldUseRule = shouldUseRule; }); -// node_modules/ajv/dist/compile/validate/dataType.js +// ../../../node_modules/ajv/dist/compile/validate/dataType.js var require_dataType = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.reportTypeError = exports.checkDataTypes = exports.checkDataType = exports.coerceAndCheckDataType = exports.getJSONTypes = exports.getSchemaTypes = exports.DataType = undefined; @@ -1639,7 +1639,7 @@ var require_dataType = __commonJS((exports) => { } }); -// node_modules/ajv/dist/compile/validate/defaults.js +// ../../../node_modules/ajv/dist/compile/validate/defaults.js var require_defaults = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.assignDefaults = undefined; @@ -1673,7 +1673,7 @@ var require_defaults = __commonJS((exports) => { } }); -// node_modules/ajv/dist/vocabularies/code.js +// ../../../node_modules/ajv/dist/vocabularies/code.js var require_code2 = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.validateUnion = exports.validateArray = exports.usePattern = exports.callValidateCode = exports.schemaProperties = exports.allSchemaProperties = exports.noPropertyInData = exports.propertyInData = exports.isOwnProperty = exports.hasPropFunc = exports.reportMissingProp = exports.checkMissingProp = exports.checkReportMissingProp = undefined; @@ -1802,7 +1802,7 @@ var require_code2 = __commonJS((exports) => { exports.validateUnion = validateUnion; }); -// node_modules/ajv/dist/compile/validate/keyword.js +// ../../../node_modules/ajv/dist/compile/validate/keyword.js var require_keyword = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.validateKeywordUsage = exports.validSchemaType = exports.funcKeywordCode = exports.macroKeywordCode = undefined; @@ -1917,7 +1917,7 @@ var require_keyword = __commonJS((exports) => { exports.validateKeywordUsage = validateKeywordUsage; }); -// node_modules/ajv/dist/compile/validate/subschema.js +// ../../../node_modules/ajv/dist/compile/validate/subschema.js var require_subschema = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.extendSubschemaMode = exports.extendSubschemaData = exports.getSubschema = undefined; @@ -1997,7 +1997,7 @@ var require_subschema = __commonJS((exports) => { exports.extendSubschemaMode = extendSubschemaMode; }); -// node_modules/fast-deep-equal/index.js +// ../../../node_modules/fast-deep-equal/index.js var require_fast_deep_equal = __commonJS((exports, module) => { module.exports = function equal(a, b) { if (a === b) @@ -2039,7 +2039,7 @@ var require_fast_deep_equal = __commonJS((exports, module) => { }; }); -// node_modules/json-schema-traverse/index.js +// ../../../node_modules/json-schema-traverse/index.js var require_json_schema_traverse = __commonJS((exports, module) => { var traverse = module.exports = function(schema, opts, cb) { if (typeof opts == "function") { @@ -2122,7 +2122,7 @@ var require_json_schema_traverse = __commonJS((exports, module) => { } }); -// node_modules/ajv/dist/compile/resolve.js +// ../../../node_modules/ajv/dist/compile/resolve.js var require_resolve = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.getSchemaRefs = exports.resolveUrl = exports.normalizeId = exports._getFullPath = exports.getFullPath = exports.inlineRef = undefined; @@ -2275,7 +2275,7 @@ var require_resolve = __commonJS((exports) => { exports.getSchemaRefs = getSchemaRefs; }); -// node_modules/ajv/dist/compile/validate/index.js +// ../../../node_modules/ajv/dist/compile/validate/index.js var require_validate = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.getData = exports.KeywordCxt = exports.validateFunctionCode = undefined; @@ -2780,7 +2780,7 @@ var require_validate = __commonJS((exports) => { exports.getData = getData; }); -// node_modules/ajv/dist/runtime/validation_error.js +// ../../../node_modules/ajv/dist/runtime/validation_error.js var require_validation_error = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); @@ -2794,7 +2794,7 @@ var require_validation_error = __commonJS((exports) => { exports.default = ValidationError; }); -// node_modules/ajv/dist/compile/ref_error.js +// ../../../node_modules/ajv/dist/compile/ref_error.js var require_ref_error = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var resolve_1 = require_resolve(); @@ -2809,7 +2809,7 @@ var require_ref_error = __commonJS((exports) => { exports.default = MissingRefError; }); -// node_modules/ajv/dist/compile/index.js +// ../../../node_modules/ajv/dist/compile/index.js var require_compile = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.resolveSchema = exports.getCompilingSchema = exports.resolveRef = exports.compileSchema = exports.SchemaEnv = undefined; @@ -3030,7 +3030,7 @@ var require_compile = __commonJS((exports) => { } }); -// node_modules/ajv/dist/refs/data.json +// ../../../node_modules/ajv/dist/refs/data.json var require_data = __commonJS((exports, module) => { module.exports = { $id: "https://raw.githubusercontent.com/ajv-validator/ajv/master/lib/refs/data.json#", @@ -3047,7 +3047,7 @@ var require_data = __commonJS((exports, module) => { }; }); -// node_modules/fast-uri/lib/utils.js +// ../../../node_modules/fast-uri/lib/utils.js var require_utils = __commonJS((exports, module) => { var isUUID = RegExp.prototype.test.bind(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iu); var isIPv4 = RegExp.prototype.test.bind(/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$/u); @@ -3302,7 +3302,7 @@ var require_utils = __commonJS((exports, module) => { }; }); -// node_modules/fast-uri/lib/schemes.js +// ../../../node_modules/fast-uri/lib/schemes.js var require_schemes = __commonJS((exports, module) => { var { isUUID } = require_utils(); var URN_REG = /([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-.:;=@]|%[\da-f]{2})+)/iu; @@ -3476,7 +3476,7 @@ var require_schemes = __commonJS((exports, module) => { }; }); -// node_modules/fast-uri/index.js +// ../../../node_modules/fast-uri/index.js var require_fast_uri = __commonJS((exports, module) => { var { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizeComponentEncoding, isIPv4, nonSimpleDomain } = require_utils(); var { SCHEMES, getSchemeHandler } = require_schemes(); @@ -3727,7 +3727,7 @@ var require_fast_uri = __commonJS((exports, module) => { module.exports.fastUri = fastUri; }); -// node_modules/ajv/dist/runtime/uri.js +// ../../../node_modules/ajv/dist/runtime/uri.js var require_uri = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var uri = require_fast_uri(); @@ -3735,7 +3735,7 @@ var require_uri = __commonJS((exports) => { exports.default = uri; }); -// node_modules/ajv/dist/core.js +// ../../../node_modules/ajv/dist/core.js var require_core = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.CodeGen = exports.Name = exports.nil = exports.stringify = exports.str = exports._ = exports.KeywordCxt = undefined; @@ -4328,7 +4328,7 @@ var require_core = __commonJS((exports) => { } }); -// node_modules/ajv/dist/vocabularies/core/id.js +// ../../../node_modules/ajv/dist/vocabularies/core/id.js var require_id = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var def = { @@ -4340,7 +4340,7 @@ var require_id = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/core/ref.js +// ../../../node_modules/ajv/dist/vocabularies/core/ref.js var require_ref = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.callRef = exports.getValidate = undefined; @@ -4459,7 +4459,7 @@ var require_ref = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/core/index.js +// ../../../node_modules/ajv/dist/vocabularies/core/index.js var require_core2 = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var id_1 = require_id(); @@ -4477,7 +4477,7 @@ var require_core2 = __commonJS((exports) => { exports.default = core2; }); -// node_modules/ajv/dist/vocabularies/validation/limitNumber.js +// ../../../node_modules/ajv/dist/vocabularies/validation/limitNumber.js var require_limitNumber = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -4506,7 +4506,7 @@ var require_limitNumber = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/validation/multipleOf.js +// ../../../node_modules/ajv/dist/vocabularies/validation/multipleOf.js var require_multipleOf = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -4531,7 +4531,7 @@ var require_multipleOf = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/runtime/ucs2length.js +// ../../../node_modules/ajv/dist/runtime/ucs2length.js var require_ucs2length = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); function ucs2length(str) { @@ -4554,7 +4554,7 @@ var require_ucs2length = __commonJS((exports) => { ucs2length.code = 'require("ajv/dist/runtime/ucs2length").default'; }); -// node_modules/ajv/dist/vocabularies/validation/limitLength.js +// ../../../node_modules/ajv/dist/vocabularies/validation/limitLength.js var require_limitLength = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -4583,7 +4583,7 @@ var require_limitLength = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/validation/pattern.js +// ../../../node_modules/ajv/dist/vocabularies/validation/pattern.js var require_pattern = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var code_1 = require_code2(); @@ -4617,7 +4617,7 @@ var require_pattern = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/validation/limitProperties.js +// ../../../node_modules/ajv/dist/vocabularies/validation/limitProperties.js var require_limitProperties = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -4643,7 +4643,7 @@ var require_limitProperties = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/validation/required.js +// ../../../node_modules/ajv/dist/vocabularies/validation/required.js var require_required = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var code_1 = require_code2(); @@ -4722,7 +4722,7 @@ var require_required = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/validation/limitItems.js +// ../../../node_modules/ajv/dist/vocabularies/validation/limitItems.js var require_limitItems = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -4748,7 +4748,7 @@ var require_limitItems = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/runtime/equal.js +// ../../../node_modules/ajv/dist/runtime/equal.js var require_equal = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var equal = require_fast_deep_equal(); @@ -4756,7 +4756,7 @@ var require_equal = __commonJS((exports) => { exports.default = equal; }); -// node_modules/ajv/dist/vocabularies/validation/uniqueItems.js +// ../../../node_modules/ajv/dist/vocabularies/validation/uniqueItems.js var require_uniqueItems = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var dataType_1 = require_dataType(); @@ -4820,7 +4820,7 @@ var require_uniqueItems = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/validation/const.js +// ../../../node_modules/ajv/dist/vocabularies/validation/const.js var require_const = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -4846,7 +4846,7 @@ var require_const = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/validation/enum.js +// ../../../node_modules/ajv/dist/vocabularies/validation/enum.js var require_enum = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -4892,7 +4892,7 @@ var require_enum = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/validation/index.js +// ../../../node_modules/ajv/dist/vocabularies/validation/index.js var require_validation = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var limitNumber_1 = require_limitNumber(); @@ -4922,7 +4922,7 @@ var require_validation = __commonJS((exports) => { exports.default = validation; }); -// node_modules/ajv/dist/vocabularies/applicator/additionalItems.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/additionalItems.js var require_additionalItems = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.validateAdditionalItems = undefined; @@ -4972,7 +4972,7 @@ var require_additionalItems = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/items.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/items.js var require_items = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.validateTuple = undefined; @@ -5026,7 +5026,7 @@ var require_items = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/prefixItems.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/prefixItems.js var require_prefixItems = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var items_1 = require_items(); @@ -5040,7 +5040,7 @@ var require_prefixItems = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/items2020.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/items2020.js var require_items2020 = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -5072,7 +5072,7 @@ var require_items2020 = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/contains.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/contains.js var require_contains = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -5163,7 +5163,7 @@ var require_contains = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/dependencies.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/dependencies.js var require_dependencies = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.validateSchemaDeps = exports.validatePropertyDeps = exports.error = undefined; @@ -5248,7 +5248,7 @@ var require_dependencies = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/propertyNames.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/propertyNames.js var require_propertyNames = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -5288,7 +5288,7 @@ var require_propertyNames = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/additionalProperties.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/additionalProperties.js var require_additionalProperties = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var code_1 = require_code2(); @@ -5391,7 +5391,7 @@ var require_additionalProperties = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/properties.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/properties.js var require_properties = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var validate_1 = require_validate(); @@ -5446,7 +5446,7 @@ var require_properties = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/patternProperties.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/patternProperties.js var require_patternProperties = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var code_1 = require_code2(); @@ -5517,7 +5517,7 @@ var require_patternProperties = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/not.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/not.js var require_not = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var util_1 = require_util(); @@ -5545,7 +5545,7 @@ var require_not = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/anyOf.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/anyOf.js var require_anyOf = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var code_1 = require_code2(); @@ -5559,7 +5559,7 @@ var require_anyOf = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/oneOf.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/oneOf.js var require_oneOf = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -5614,7 +5614,7 @@ var require_oneOf = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/allOf.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/allOf.js var require_allOf = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var util_1 = require_util(); @@ -5638,7 +5638,7 @@ var require_allOf = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/if.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/if.js var require_if = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -5704,7 +5704,7 @@ var require_if = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/thenElse.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/thenElse.js var require_thenElse = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var util_1 = require_util(); @@ -5719,7 +5719,7 @@ var require_thenElse = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/applicator/index.js +// ../../../node_modules/ajv/dist/vocabularies/applicator/index.js var require_applicator = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var additionalItems_1 = require_additionalItems(); @@ -5762,7 +5762,7 @@ var require_applicator = __commonJS((exports) => { exports.default = getApplicator; }); -// node_modules/ajv/dist/vocabularies/format/format.js +// ../../../node_modules/ajv/dist/vocabularies/format/format.js var require_format = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -5849,7 +5849,7 @@ var require_format = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/vocabularies/format/index.js +// ../../../node_modules/ajv/dist/vocabularies/format/index.js var require_format2 = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var format_1 = require_format(); @@ -5857,7 +5857,7 @@ var require_format2 = __commonJS((exports) => { exports.default = format; }); -// node_modules/ajv/dist/vocabularies/metadata.js +// ../../../node_modules/ajv/dist/vocabularies/metadata.js var require_metadata = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.contentVocabulary = exports.metadataVocabulary = undefined; @@ -5877,7 +5877,7 @@ var require_metadata = __commonJS((exports) => { ]; }); -// node_modules/ajv/dist/vocabularies/draft7.js +// ../../../node_modules/ajv/dist/vocabularies/draft7.js var require_draft7 = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var core_1 = require_core2(); @@ -5896,7 +5896,7 @@ var require_draft7 = __commonJS((exports) => { exports.default = draft7Vocabularies; }); -// node_modules/ajv/dist/vocabularies/discriminator/types.js +// ../../../node_modules/ajv/dist/vocabularies/discriminator/types.js var require_types = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.DiscrError = undefined; @@ -5907,7 +5907,7 @@ var require_types = __commonJS((exports) => { })(DiscrError || (exports.DiscrError = DiscrError = {})); }); -// node_modules/ajv/dist/vocabularies/discriminator/index.js +// ../../../node_modules/ajv/dist/vocabularies/discriminator/index.js var require_discriminator = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); var codegen_1 = require_codegen(); @@ -6009,7 +6009,7 @@ var require_discriminator = __commonJS((exports) => { exports.default = def; }); -// node_modules/ajv/dist/refs/json-schema-draft-07.json +// ../../../node_modules/ajv/dist/refs/json-schema-draft-07.json var require_json_schema_draft_07 = __commonJS((exports, module) => { module.exports = { $schema: "http://json-schema.org/draft-07/schema#", @@ -6164,7 +6164,7 @@ var require_json_schema_draft_07 = __commonJS((exports, module) => { }; }); -// node_modules/ajv/dist/ajv.js +// ../../../node_modules/ajv/dist/ajv.js var require_ajv = __commonJS((exports, module) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.MissingRefError = exports.ValidationError = exports.CodeGen = exports.Name = exports.nil = exports.stringify = exports.str = exports._ = exports.KeywordCxt = exports.Ajv = undefined; @@ -6232,7 +6232,7 @@ var require_ajv = __commonJS((exports, module) => { } }); }); -// node_modules/ajv-formats/dist/formats.js +// ../../../node_modules/ajv-formats/dist/formats.js var require_formats = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.formatNames = exports.fastFormats = exports.fullFormats = undefined; @@ -6409,7 +6409,7 @@ var require_formats = __commonJS((exports) => { } }); -// node_modules/ajv-formats/dist/limit.js +// ../../../node_modules/ajv-formats/dist/limit.js var require_limit = __commonJS((exports) => { Object.defineProperty(exports, "__esModule", { value: true }); exports.formatLimitDefinition = undefined; @@ -6478,7 +6478,7 @@ var require_limit = __commonJS((exports) => { exports.default = formatLimitPlugin; }); -// node_modules/ajv-formats/dist/index.js +// ../../../node_modules/ajv-formats/dist/index.js var require_dist = __commonJS((exports, module) => { Object.defineProperty(exports, "__esModule", { value: true }); var formats_1 = require_formats(); @@ -6520,7 +6520,7 @@ var require_dist = __commonJS((exports, module) => { // src/bridge.ts import { existsSync as existsSync7 } from "fs"; -// node_modules/zod/v4/core/core.js +// ../../../node_modules/zod/v4/core/core.js var NEVER = Object.freeze({ status: "aborted" }); @@ -6596,7 +6596,7 @@ function config(newConfig) { Object.assign(globalConfig, newConfig); return globalConfig; } -// node_modules/zod/v4/core/util.js +// ../../../node_modules/zod/v4/core/util.js var exports_util = {}; __export(exports_util, { unwrapMessage: () => unwrapMessage, @@ -7270,7 +7270,7 @@ class Class { constructor(..._args) {} } -// node_modules/zod/v4/core/errors.js +// ../../../node_modules/zod/v4/core/errors.js var initializer = (inst, def) => { inst.name = "$ZodError"; Object.defineProperty(inst, "_zod", { @@ -7336,7 +7336,7 @@ function formatError(error, mapper = (issue2) => issue2.message) { return fieldErrors; } -// node_modules/zod/v4/core/parse.js +// ../../../node_modules/zod/v4/core/parse.js var _parse = (_Err) => (schema, value, _ctx, _params) => { const ctx = _ctx ? Object.assign(_ctx, { async: false }) : { async: false }; const result = schema._zod.run({ value, issues: [] }, ctx); @@ -7413,7 +7413,7 @@ var _safeEncodeAsync = (_Err) => async (schema, value, _ctx) => { var _safeDecodeAsync = (_Err) => async (schema, value, _ctx) => { return _safeParseAsync(_Err)(schema, value, _ctx); }; -// node_modules/zod/v4/core/regexes.js +// ../../../node_modules/zod/v4/core/regexes.js var cuid = /^[cC][^\s-]{8,}$/; var cuid2 = /^[0-9a-z]+$/; var ulid = /^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/; @@ -7470,7 +7470,7 @@ var _null = /^null$/i; var lowercase = /^[^A-Z]*$/; var uppercase = /^[^a-z]*$/; -// node_modules/zod/v4/core/checks.js +// ../../../node_modules/zod/v4/core/checks.js var $ZodCheck = /* @__PURE__ */ $constructor("$ZodCheck", (inst, def) => { var _a; inst._zod ?? (inst._zod = {}); @@ -7859,7 +7859,7 @@ var $ZodCheckOverwrite = /* @__PURE__ */ $constructor("$ZodCheckOverwrite", (ins }; }); -// node_modules/zod/v4/core/doc.js +// ../../../node_modules/zod/v4/core/doc.js class Doc { constructor(args = []) { this.content = []; @@ -7897,14 +7897,14 @@ class Doc { } } -// node_modules/zod/v4/core/versions.js +// ../../../node_modules/zod/v4/core/versions.js var version = { major: 4, minor: 3, patch: 6 }; -// node_modules/zod/v4/core/schemas.js +// ../../../node_modules/zod/v4/core/schemas.js var $ZodType = /* @__PURE__ */ $constructor("$ZodType", (inst, def) => { var _a; inst ?? (inst = {}); @@ -9289,7 +9289,7 @@ function handleRefineResult(result, payload, input, inst) { payload.issues.push(issue(_iss)); } } -// node_modules/zod/v4/locales/en.js +// ../../../node_modules/zod/v4/locales/en.js var error = () => { const Sizable = { string: { unit: "characters", verb: "to have" }, @@ -9395,7 +9395,7 @@ function en_default() { localeError: error() }; } -// node_modules/zod/v4/core/registries.js +// ../../../node_modules/zod/v4/core/registries.js var _a; var $output = Symbol("ZodOutput"); var $input = Symbol("ZodInput"); @@ -9445,7 +9445,7 @@ function registry() { } (_a = globalThis).__zod_globalRegistry ?? (_a.__zod_globalRegistry = registry()); var globalRegistry = globalThis.__zod_globalRegistry; -// node_modules/zod/v4/core/api.js +// ../../../node_modules/zod/v4/core/api.js function _string(Class2, params) { return new Class2({ type: "string", @@ -9911,7 +9911,7 @@ function _check(fn, params) { ch._zod.check = fn; return ch; } -// node_modules/zod/v4/core/to-json-schema.js +// ../../../node_modules/zod/v4/core/to-json-schema.js function initializeContext(params) { let target = params?.target ?? "draft-2020-12"; if (target === "draft-4") @@ -10256,7 +10256,7 @@ var createStandardJSONSchemaMethod = (schema, io, processors = {}) => (params) = extractDefs(ctx, schema); return finalize(ctx, schema); }; -// node_modules/zod/v4/core/json-schema-processors.js +// ../../../node_modules/zod/v4/core/json-schema-processors.js var formatMap = { guid: "uuid", url: "uri", @@ -10591,7 +10591,7 @@ var optionalProcessor = (schema, ctx, _json, params) => { const seen = ctx.seen.get(schema); seen.ref = def.innerType; }; -// node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-compat.js +// ../../../node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-compat.js function isZ4Schema(s) { const schema = s; return !!schema._zod; @@ -10653,7 +10653,7 @@ function getLiteralValue(schema) { return directValue; return; } -// node_modules/zod/v4/classic/iso.js +// ../../../node_modules/zod/v4/classic/iso.js var exports_iso = {}; __export(exports_iso, { time: () => time2, @@ -10694,7 +10694,7 @@ function duration2(params) { return _isoDuration(ZodISODuration, params); } -// node_modules/zod/v4/classic/errors.js +// ../../../node_modules/zod/v4/classic/errors.js var initializer2 = (inst, issues) => { $ZodError.init(inst, issues); inst.name = "ZodError"; @@ -10729,7 +10729,7 @@ var ZodRealError = $constructor("ZodError", initializer2, { Parent: Error }); -// node_modules/zod/v4/classic/parse.js +// ../../../node_modules/zod/v4/classic/parse.js var parse3 = /* @__PURE__ */ _parse(ZodRealError); var parseAsync2 = /* @__PURE__ */ _parseAsync(ZodRealError); var safeParse3 = /* @__PURE__ */ _safeParse(ZodRealError); @@ -10743,7 +10743,7 @@ var safeDecode2 = /* @__PURE__ */ _safeDecode(ZodRealError); var safeEncodeAsync2 = /* @__PURE__ */ _safeEncodeAsync(ZodRealError); var safeDecodeAsync2 = /* @__PURE__ */ _safeDecodeAsync(ZodRealError); -// node_modules/zod/v4/classic/schemas.js +// ../../../node_modules/zod/v4/classic/schemas.js var ZodType = /* @__PURE__ */ $constructor("ZodType", (inst, def) => { $ZodType.init(inst, def); Object.assign(inst["~standard"], { @@ -11382,10 +11382,10 @@ function superRefine(fn) { function preprocess(fn, schema) { return pipe(transform(fn), schema); } -// node_modules/zod/v4/classic/external.js +// ../../../node_modules/zod/v4/classic/external.js config(en_default()); -// node_modules/@modelcontextprotocol/sdk/dist/esm/types.js +// ../../../node_modules/@modelcontextprotocol/sdk/dist/esm/types.js var LATEST_PROTOCOL_VERSION = "2025-11-25"; var SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, "2025-06-18", "2025-03-26", "2024-11-05", "2024-10-07"]; var RELATED_TASK_META_KEY = "io.modelcontextprotocol/related-task"; @@ -12217,16 +12217,16 @@ class UrlElicitationRequiredError extends McpError { } } -// node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/interfaces.js +// ../../../node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/interfaces.js function isTerminal(status) { return status === "completed" || status === "failed" || status === "cancelled"; } -// node_modules/zod-to-json-schema/dist/esm/Options.js +// ../../../node_modules/zod-to-json-schema/dist/esm/Options.js var ignoreOverride = Symbol("Let zodToJsonSchema decide on which parser to use"); -// node_modules/zod-to-json-schema/dist/esm/parsers/string.js +// ../../../node_modules/zod-to-json-schema/dist/esm/parsers/string.js var ALPHA_NUMERIC = new Set("ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvxyz0123456789"); -// node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-json-schema-compat.js +// ../../../node_modules/@modelcontextprotocol/sdk/dist/esm/server/zod-json-schema-compat.js function getMethodLiteral(schema) { const shape = getObjectShape(schema); const methodSchema = shape?.method; @@ -12247,7 +12247,7 @@ function parseWithCompat(schema, data) { return result.data; } -// node_modules/@modelcontextprotocol/sdk/dist/esm/shared/protocol.js +// ../../../node_modules/@modelcontextprotocol/sdk/dist/esm/shared/protocol.js var DEFAULT_REQUEST_TIMEOUT_MSEC = 60000; class Protocol { @@ -13082,7 +13082,7 @@ function mergeCapabilities(base, additional) { return result; } -// node_modules/@modelcontextprotocol/sdk/dist/esm/validation/ajv-provider.js +// ../../../node_modules/@modelcontextprotocol/sdk/dist/esm/validation/ajv-provider.js var import_ajv = __toESM(require_ajv(), 1); var import_ajv_formats = __toESM(require_dist(), 1); function createDefaultAjvInstance() { @@ -13122,7 +13122,7 @@ class AjvJsonSchemaValidator { } } -// node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/server.js +// ../../../node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/server.js class ExperimentalServerTasks { constructor(_server) { this._server = _server; @@ -13200,7 +13200,7 @@ class ExperimentalServerTasks { } } -// node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/helpers.js +// ../../../node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/helpers.js function assertToolsCallTaskCapability(requests, method, entityName) { if (!requests) { throw new Error(`${entityName} does not support task creation (required for ${method})`); @@ -13235,7 +13235,7 @@ function assertClientRequestTaskCapability(requests, method, entityName) { } } -// node_modules/@modelcontextprotocol/sdk/dist/esm/server/index.js +// ../../../node_modules/@modelcontextprotocol/sdk/dist/esm/server/index.js class Server extends Protocol { constructor(_serverInfo, options) { super(options); @@ -13568,10 +13568,10 @@ class Server extends Protocol { } } -// node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js +// ../../../node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js import process3 from "process"; -// node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js +// ../../../node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js class ReadBuffer { append(chunk) { this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; @@ -13601,7 +13601,7 @@ function serializeMessage(message) { `; } -// node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js +// ../../../node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js class StdioServerTransport { constructor(_stdin = process3.stdin, _stdout = process3.stdout) { this._stdin = _stdin; @@ -14707,10 +14707,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("fdc78c6", "source"), + commit: defineString("2d64afa", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("ace71209327c", "source") + codeHash: defineString("e9f689afd6b6", "source") }); function sameRuntimeContract(a, b) { if (!a || !b) diff --git a/plugins/agentbridge/server/daemon.js b/plugins/agentbridge/server/daemon.js index 59a3354..ce7e2da 100755 --- a/plugins/agentbridge/server/daemon.js +++ b/plugins/agentbridge/server/daemon.js @@ -30,10 +30,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("fdc78c6", "source"), + commit: defineString("2d64afa", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("ace71209327c", "source") + codeHash: defineString("e9f689afd6b6", "source") }); function daemonStatusBuildInfo() { return { ...BUILD_INFO }; diff --git a/src/cli.ts b/src/cli.ts index 6fa3e7e..3a0defa 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -184,11 +184,21 @@ Commands: doctor [--json] Diagnose env, daemon, build drift, logs, and current thread doctor resume-pollution [--apply] Find/fix old AgentBridge kickoff metadata budget [--json] Show both agents' subscription quota snapshot (5h/weekly, drift, pause state) + auth issue --id --name + On the broker: issue a PSK token for someone else and PRINT it (carry it + out-of-band; does not touch /auth-token) + auth login --token + On the edge: install a broker-issued token to /auth-token (0600) auth login --id --name - Issue a collaboration PSK token and write it to /auth-token (0600) + Self-sign a token locally (single-machine case) and write it (0600) room create | room list Create a collaboration room (id = slugified name) or list rooms - join Join a room and auto-join this directory next time (§2.4) + room invite [--name ] [--broker-url ] + On the broker: issue a token + grant membership + print the invitee's + full join commands (one-shot cross-network onboarding). Pass the routable + --broker-url from "abg broker start"'s card so the invitee can actually reach you + join Join a room and auto-join this directory next time (§2.4). For a remote + room (no local record) it maps the cwd; the broker enforces membership broker start [--host ] [--port ] [--db ] [--web-port ] [--no-web] [--no-open] Run the always-on control-plane broker (§11.1) + a loopback-only admin dashboard (view rooms/members/whiteboards + create a room) diff --git a/src/cli/auth.ts b/src/cli/auth.ts index f37c4d2..92fc057 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -1,10 +1,17 @@ /** - * `abg auth login` — issue a PSK token bound to a collaboration identity (§2.2, §6). + * `abg auth issue / login` — the cross-network onboarding primitives (§2.2, §6). * - * Registers (or refreshes) a person identity in the local collab Store, issues a - * fresh PSK token, and writes it to `/auth-token` (0600) for the broker to - * verify via StorePskIdentityProvider. The Store double as the (token → identity) - * binding source, so a freshly-issued token authenticates without a broker restart. + * The broker verifies a presented token against ITS OWN Store (StorePskIdentityProvider), + * so a token only works if it was issued by the broker's Store. Two roles: + * + * - `auth issue --id --name` (run ON the broker): register + issueToken in the broker's + * collab.db and PRINT the token for the operator to carry out-of-band. Does NOT write a + * local auth-token (the token is for someone else). + * - `auth login --token ` (run on the EDGE): install the broker-issued token into + * `/auth-token` (0600). No register / no issue — the binding already lives in the + * broker's Store. + * - `auth login --id --name` (legacy self-sign): register + issue + install locally, for the + * single-machine case where the same Store is both issuer and verifier. */ import { chmodSync, mkdirSync } from "node:fs"; @@ -26,6 +33,11 @@ export interface AuthLoginResult { tokenFile: string; } +export interface AuthIssueResult { + token: string; + identity: { id: string; displayName: string }; +} + /** Resolve the collab DB path: explicit > env override > `/collab.db`. */ function resolveDbPath(dbPath?: string): string { if (dbPath) return dbPath; @@ -35,56 +47,113 @@ function resolveDbPath(dbPath?: string): string { } /** - * Register the identity, issue a token, and persist it next to the collab DB. - * Directly unit-testable: pass an explicit `dbPath` to a temp dir. + * Lock the collab dir to 0700. The collab DB holds RAW PSK tokens (auth_tokens) + identity + * emails/PII (identities). bun:sqlite creates the DB file 0644, and its WAL/SHM sidecars are + * recreated 0644 on every reopen, so file-level chmod is not durable — lock the CONTAINING + * directory instead (matches codex-transport.ts), blocking any other local user from + * traversing in to read the secrets (CWE-732). chmodSync covers a pre-existing looser dir. */ -export async function authLogin(opts: AuthLoginOptions): Promise { - const dbPath = resolveDbPath(opts.dbPath); +function lockCollabDir(dbPath: string): string { const dir = dirname(dbPath); - // The collab DB holds RAW PSK tokens (auth_tokens) + identity emails/PII - // (identities). bun:sqlite creates the DB file 0644, and its WAL/SHM sidecars - // are recreated 0644 on every reopen, so file-level chmod is not durable — - // lock the CONTAINING directory to 0700 instead (matches codex-transport.ts), - // blocking any other local user from traversing in to read the secrets - // (CWE-732). chmodSync covers the case where the dir already existed looser. mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700); + return dir; +} +/** Lock the dir, register the identity, and issue a token in this Store. Shared by login + issue. */ +async function registerAndIssue( + dbPath: string, + id: string, + name: string, +): Promise<{ token: string; identity: { id: string; displayName: string }; dir: string }> { + const dir = lockCollabDir(dbPath); const store = new SqliteStore(dbPath); try { const svc = new IdentityService(store); - const identity = await svc.registerIdentity(opts.id, opts.name); + const identity = await svc.registerIdentity(id, name); const token = await svc.issueToken(identity.id); - const tokenFile = join(dir, "auth-token"); - // 0600 from creation (CWE-732): the token is a local secret. - atomicWriteText(tokenFile, token, { mode: 0o600 }); - return { token, identity, tokenFile }; + return { token, identity, dir }; } finally { await store.close(); } } -const LOGIN_USAGE = "用法:abg auth login --id --name "; +/** + * Self-sign: register the identity, issue a token, and persist it next to the collab DB. + * Single-machine path — the same Store both issues and (as the broker) verifies. Directly + * unit-testable: pass an explicit `dbPath` to a temp dir. + */ +export async function authLogin(opts: AuthLoginOptions): Promise { + const dbPath = resolveDbPath(opts.dbPath); + const { token, identity, dir } = await registerAndIssue(dbPath, opts.id, opts.name); + const tokenFile = join(dir, "auth-token"); + // 0600 from creation (CWE-732): the token is a local secret. + atomicWriteText(tokenFile, token, { mode: 0o600 }); + return { token, identity, tokenFile }; +} + +/** + * Issue a token FROM the broker's Store for someone else to install (`abg auth issue`). + * Register + issue into this machine's collab.db, then return the token to PRINT. Deliberately + * does NOT write a local auth-token — this token belongs to the invitee, not the operator. + */ +export async function authIssue(opts: AuthLoginOptions): Promise { + const dbPath = resolveDbPath(opts.dbPath); + const { token, identity } = await registerAndIssue(dbPath, opts.id, opts.name); + return { token, identity }; +} -/** Parse `--id`/`--name` (space- or `=`-separated) and run the login. */ -export async function runAuthLoginCli(argv: string[]): Promise { +/** + * Install a broker-issued token on the edge (`abg auth login --token `). Writes the + * out-of-band token to `/auth-token` (0600) and locks the dir — NO register / NO issue, + * because the (token → identity) binding already lives in the broker's Store. An empty token is + * rejected (it would silently disable auth). + */ +export async function installToken(opts: { token: string; dbPath?: string }): Promise<{ tokenFile: string }> { + const token = opts.token.trim(); + if (token === "") throw new Error("令牌为空:abg auth login --token 需要 broker 签发的非空令牌"); + const dbPath = resolveDbPath(opts.dbPath); + const dir = lockCollabDir(dbPath); + const tokenFile = join(dir, "auth-token"); + atomicWriteText(tokenFile, token, { mode: 0o600 }); + return { tokenFile }; +} + +const LOGIN_USAGE = + "用法:abg auth login --token (边机安装 broker 签发的令牌)|abg auth login --id --name (本机自签)"; +const ISSUE_USAGE = "用法:abg auth issue --id --name (在 broker 机上签发,把令牌带外发给对方)"; + +/** Parse `--id`/`--name`/`--token` (space- or `=`-separated). Empty values become "". */ +function parseAuthArgs(argv: string[]): { id?: string; name?: string; token?: string } { let id: string | undefined; let name: string | undefined; + let token: string | undefined; for (let i = 0; i < argv.length; i++) { const a = argv[i]!; - if (a === "--id") { - id = argv[++i]; - } else if (a.startsWith("--id=")) { - id = a.slice("--id=".length); - } else if (a === "--name") { - name = argv[++i]; - } else if (a.startsWith("--name=")) { - name = a.slice("--name=".length); - } + if (a === "--id") id = argv[++i] ?? ""; + else if (a.startsWith("--id=")) id = a.slice("--id=".length); + else if (a === "--name") name = argv[++i] ?? ""; + else if (a.startsWith("--name=")) name = a.slice("--name=".length); + else if (a === "--token") token = argv[++i] ?? ""; + else if (a.startsWith("--token=")) token = a.slice("--token=".length); + } + return { id, name, token }; +} + +/** Run `abg auth login`: `--token` installs a broker token; `--id --name` self-signs. */ +export async function runAuthLoginCli(argv: string[]): Promise { + const { id, name, token } = parseAuthArgs(argv); + + // --token mode: install the broker-issued token (an empty value is caught by installToken). + if (token !== undefined) { + const { tokenFile } = await installToken({ token }); + console.log(`已安装协作令牌(broker 签发):${tokenFile}`); + console.log("现在可以:abg join "); + return; } if (!id || !name) { - console.error("缺少必填参数 --id 或 --name。"); + console.error("缺少必填参数:要么 --token ,要么 --id <…> --name <…>。"); console.error(LOGIN_USAGE); process.exit(1); return; @@ -97,16 +166,36 @@ export async function runAuthLoginCli(argv: string[]): Promise { console.log(`令牌文件:${result.tokenFile}`); } -/** Dispatch `abg auth `. Only `login` is supported today. */ +/** Run `abg auth issue`: sign a token on the broker for an invitee to install out-of-band. */ +export async function runAuthIssueCli(argv: string[]): Promise { + const { id, name } = parseAuthArgs(argv); + if (!id || !name) { + console.error("缺少必填参数 --id 或 --name。"); + console.error(ISSUE_USAGE); + process.exit(1); + return; + } + const result = await authIssue({ id, name }); + console.log(`已在本机(broker)store 为 ${result.identity.id}(${result.identity.displayName})签发令牌。`); + console.log("把下面这行通过安全渠道带外发给对方,让它在自己机器上运行:"); + console.log(` abg auth login --token ${result.token}`); + console.log("(注:对同一 --id 重复 issue 会另签新 token、旧 token 不会自动失效——令牌吊销 CLI 仍在 backlog。)"); +} + +/** Dispatch `abg auth `: `login` (install/self-sign) or `issue` (broker-side sign). */ export async function runAuth(args: string[]): Promise { const sub = args[0]; switch (sub) { case "login": await runAuthLoginCli(args.slice(1)); break; + case "issue": + await runAuthIssueCli(args.slice(1)); + break; default: console.error(`未知的 auth 子命令:${sub ?? "(空)"}`); console.error(LOGIN_USAGE); + console.error(ISSUE_USAGE); process.exit(1); } } diff --git a/src/cli/room.ts b/src/cli/room.ts index 3a48d90..c1fe532 100644 --- a/src/cli/room.ts +++ b/src/cli/room.ts @@ -12,8 +12,10 @@ import { chmodSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { RoomService, slugify } from "../room-service"; +import { IdentityService } from "../backbone/identity-service"; import { SqliteStore } from "../backbone/store/sqlite-store"; import type { RoomRecord, Store } from "../backbone/store"; +import { resolveBrokerUrl } from "../collab-store"; import { StateDirResolver } from "../state-dir"; /** Resolve the collab DB path: explicit > env override > `/collab.db`. */ @@ -97,31 +99,45 @@ export async function listRooms(opts: { dbPath?: string }): Promise { +}): Promise<{ roomId: string; agentId: string | null; local: boolean }> { const dbPath = resolveDbPath(opts.dbPath); const store = openStore(dbPath); try { - const agentId = await currentIdentityId(store, dbPath); const svc = new RoomService(store); - if ((await svc.getRoom(opts.roomId)) === null) { - throw new Error(`房间不存在:${opts.roomId}(先用 abg room create 创建)`); - } - if (!(await svc.isMember(opts.roomId, agentId))) { - throw new Error(`你(${agentId})不是 ${opts.roomId} 的成员;请让房间成员在 broker 机上 abg room add ${agentId}`); + if ((await svc.getRoom(opts.roomId)) !== null) { + // Local room → strict membership check (unchanged). Resolving the identity also + // requires a valid local login, which is correct for the single-machine path. + const agentId = await currentIdentityId(store, dbPath); + if (!(await svc.isMember(opts.roomId, agentId))) { + throw new Error(`你(${agentId})不是 ${opts.roomId} 的成员;请让房间成员在 broker 机上 abg room add ${agentId}`); + } + await svc.mapCwd(opts.cwd ?? process.cwd(), opts.roomId); + return { roomId: opts.roomId, agentId, local: true }; } + // Remote room: no local record. Don't resolve identity (a broker-issued token won't + // resolve against this edge's empty Store) — just map the cwd; the broker enforces + // membership at subscribe time. await svc.mapCwd(opts.cwd ?? process.cwd(), opts.roomId); - return { roomId: opts.roomId, agentId }; + return { roomId: opts.roomId, agentId: null, local: false }; } finally { await store.close(); } @@ -150,6 +166,50 @@ export async function addRoomMember(opts: { roomId: string; identityId: string; } } +/** + * One-shot cross-network onboarding (§ onboarding): invite `identityId` into `roomId` ON THE + * BROKER. Registers the invitee's identity, issues a PSK token for it (so the token authenticates + * against the broker's StorePskIdentityProvider), and grants membership — then returns the token + + * broker URL so the caller can print the exact commands to hand the invitee. + * + * Authorization: same gate as `addRoomMember` — the caller must already be a member; only insiders + * can invite (§11.2). A fresh `--name` updates the display name; otherwise an existing name is kept + * (never clobbered with the id). + */ +export async function inviteRoomMember(opts: { + roomId: string; + identityId: string; + name?: string; + /** Broker URL to print for the invitee. Falls back to AGENTBRIDGE_BROKER_URL > loopback default. */ + brokerUrl?: string; + dbPath?: string; +}): Promise<{ token: string; brokerUrl: string }> { + const dbPath = resolveDbPath(opts.dbPath); + const store = openStore(dbPath); + try { + const caller = await currentIdentityId(store, dbPath); + const roomSvc = new RoomService(store); + if ((await roomSvc.getRoom(opts.roomId)) === null) throw new Error(`房间不存在:${opts.roomId}(先 abg room create)`); + if (!(await roomSvc.isMember(opts.roomId, caller))) { + throw new Error(`只有房间成员能邀请;你(${caller})不是 ${opts.roomId} 的成员`); + } + const idSvc = new IdentityService(store); + const existing = await idSvc.getIdentity(opts.identityId); + const displayName = opts.name ?? existing?.displayName ?? opts.identityId; + await idSvc.registerIdentity(opts.identityId, displayName); + const token = await idSvc.issueToken(opts.identityId); + await roomSvc.join(opts.roomId, opts.identityId); + return { token, brokerUrl: resolveBrokerUrl(opts.brokerUrl) }; + } finally { + await store.close(); + } +} + +/** True iff `url`'s host is loopback — the invitee can't reach the broker through it. */ +export function isLoopbackBrokerUrl(url: string): boolean { + return /:\/\/(127\.\d+\.\d+\.\d+|localhost|\[::1\]|::1)(:|\/|$)/.test(url); +} + /** Remove `identityId` from `roomId`. Caller must be a member (§11.2). */ export async function removeRoomMember(opts: { roomId: string; identityId: string; dbPath?: string }): Promise { const dbPath = resolveDbPath(opts.dbPath); @@ -157,6 +217,7 @@ export async function removeRoomMember(opts: { roomId: string; identityId: strin try { const caller = await currentIdentityId(store, dbPath); const svc = new RoomService(store); + if ((await svc.getRoom(opts.roomId)) === null) throw new Error(`房间不存在:${opts.roomId}`); if (!(await svc.isMember(opts.roomId, caller))) { throw new Error(`只有房间成员能移除成员;你(${caller})不是 ${opts.roomId} 的成员`); } @@ -167,7 +228,7 @@ export async function removeRoomMember(opts: { roomId: string; identityId: strin } const ROOM_USAGE = - "用法:abg room create | abg room list | abg room add | abg room remove "; + "用法:abg room create | abg room list | abg room invite [--name ] [--broker-url ] | abg room add | abg room remove "; /** Dispatch `abg room `: `create ` / `list`. */ export async function runRoom(args: string[]): Promise { @@ -200,6 +261,42 @@ export async function runRoom(args: string[]): Promise { } break; } + case "invite": { + const roomId = args[1]; + const identityId = args[2]; + // Optional `--name ` / `--broker-url ` (space- or `=`-separated). + let name: string | undefined; + let brokerUrl: string | undefined; + for (let i = 3; i < args.length; i++) { + const a = args[i]!; + if (a === "--name") name = args[++i]; + else if (a.startsWith("--name=")) name = a.slice("--name=".length); + else if (a === "--broker-url") brokerUrl = args[++i]; + else if (a.startsWith("--broker-url=")) brokerUrl = a.slice("--broker-url=".length); + } + if (!roomId || !identityId) { + console.error("用法:abg room invite [--name ] [--broker-url ]"); + process.exit(1); + return; + } + const { token, brokerUrl: url } = await inviteRoomMember({ roomId, identityId, name, brokerUrl }); + console.log(`已邀请 ${identityId} 加入房间 ${roomId}。把下面三行通过安全渠道带外发给 ${identityId},让它在自己机器上运行:`); + console.log(` export AGENTBRIDGE_BROKER_URL=${url}`); + console.log(` abg auth login --token ${token}`); + console.log(` abg join ${roomId}`); + console.log(""); + console.log("提示:AGENTBRIDGE_BROKER_URL 要在 daemon 启动那一刻就已设好、且持久——建议写进 ~/.zshrc / ~/.bashrc。"); + console.log("daemon 在启动时读一次该变量;若 daemon 已在跑、或新开终端没设它,会回退本机 ws://127.0.0.1:4700/ws、"); + console.log("静默收不到房间事件。设好变量后,先 agentbridge kill 再 agentbridge claude,让 daemon 带上这个地址。"); + console.log("(注:重复 invite 会另签一个新 token、旧 token 不会自动失效——令牌吊销 CLI 仍在 backlog。)"); + if (isLoopbackBrokerUrl(url)) { + console.log(""); + console.log(`⚠️ 上面的 broker 地址 ${url} 仅本机可达,对方跨机连不上。请改用 broker 机的可路由地址重发:`); + console.log(` abg room invite ${roomId} ${identityId} --broker-url ws://:4700/ws`); + console.log(" (该地址见 broker 机上 abg broker start 打印的连接卡)"); + } + break; + } case "add": case "remove": { const roomId = args[1]; @@ -234,5 +331,9 @@ export async function runJoin(args: string[]): Promise { return; } const result = await joinRoom({ roomId }); - console.log(`已把当前目录关联到房间 ${result.roomId}(agent ${result.agentId},你已是成员);该目录今后会自动加入`); + if (result.local) { + console.log(`已把当前目录关联到房间 ${result.roomId}(agent ${result.agentId},你已是成员);该目录今后会自动加入`); + } else { + console.log(`本地无房间 ${result.roomId} 的记录,已把当前目录映射过去(远程房间);连接 broker 时由其校验成员制——只有成员能订阅/发布。该目录今后会自动加入`); + } } diff --git a/src/net-detect.ts b/src/net-detect.ts index 8a8e586..b97389a 100644 --- a/src/net-detect.ts +++ b/src/net-detect.ts @@ -143,17 +143,25 @@ export function buildConnectionCard(opts: { } lines.push(` 本机: ${bindHost || "127.0.0.1"} (仅本机)`); - // ── 协作者命令块 ── + // ── 邀请协作者(在本机 broker 上运行)── + // 注意:edge 自签的令牌进不了 broker 的 store,必须由 broker 侧签发。`abg room invite` + // 一条龙完成「签发令牌 + 授权入房 + 打印对方要跑的完整命令(含 broker 地址)」。 lines.push(""); - lines.push("── 把下面的命令发给协作者 ──────────────────────────"); + lines.push("── 邀请协作者加入(在本机 broker 上运行)──────────────"); if (primary) { - lines.push(` export AGENTBRIDGE_BROKER_URL=ws://${primary}:${brokerPort}/ws`); + lines.push(` broker 地址:ws://${primary}:${brokerPort}/ws`); } else { lines.push(` # ⚠️ broker 只本机可达,跨机请先绑定可路由地址(见下方指引)`); - lines.push(` export AGENTBRIDGE_BROKER_URL=ws://127.0.0.1:${brokerPort}/ws`); + lines.push(` broker 地址:ws://127.0.0.1:${brokerPort}/ws`); + } + lines.push(" 邀请某人加入房间(签发令牌 + 授权 + 打印对方要跑的完整命令):"); + if (primary) { + // Embed the routable address so `room invite` hands the invitee a reachable URL + // (it otherwise falls back to a loopback default — useless cross-machine). + lines.push(` abg room invite <对方id> --broker-url ws://${primary}:${brokerPort}/ws`); + } else { + lines.push(" abg room invite <对方id>"); } - lines.push(" abg auth login --token <带外分发的 PSK>"); - lines.push(" abg join "); // ── Tailscale 未运行指引 ── if (!tailscale.running) { diff --git a/src/unit-test/cli-auth.test.ts b/src/unit-test/cli-auth.test.ts index 2794b16..da4b7c7 100644 --- a/src/unit-test/cli-auth.test.ts +++ b/src/unit-test/cli-auth.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { authLogin } from "../cli/auth"; +import { authIssue, authLogin, installToken } from "../cli/auth"; import { StorePskIdentityProvider } from "../backbone/identity/store-psk-identity-provider"; import { SqliteStore } from "../backbone/store/sqlite-store"; @@ -57,3 +57,65 @@ describe("authLogin", () => { expect(statSync(collabDir).mode & 0o777).toBe(0o700); }); }); + +describe("authIssue (broker-side sign)", () => { + let dir: string | undefined; + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + it("issues a broker-verifiable token but does NOT write a local auth-token", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-issue-")); + const dbPath = join(dir, "collab.db"); + + const result = await authIssue({ id: "edge@x.com", name: "Edge", dbPath }); + + expect(result.token).toBeTruthy(); + expect(result.identity).toEqual({ id: "edge@x.com", displayName: "Edge" }); + // The token is for SOMEONE ELSE — the broker operator must not adopt it locally. + expect(existsSync(join(dir, "auth-token"))).toBe(false); + // dir still locked down (raw token + PII at rest). + expect(statSync(dir).mode & 0o777).toBe(0o700); + + // The issued token authenticates against the broker's provider. + const store = new SqliteStore(dbPath); + try { + const identity = await new StorePskIdentityProvider(store).authenticate(result.token); + expect(identity).toEqual({ id: "edge@x.com", displayName: "Edge" }); + } finally { + await store.close(); + } + }); +}); + +describe("installToken (edge: abg auth login --token)", () => { + let dir: string | undefined; + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + it("writes the broker-issued token 0600 without registering/issuing anything", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-install-")); + const dbPath = join(dir, "collab.db"); + + const { tokenFile } = await installToken({ token: " opaque-broker-token ", dbPath }); + + expect(readFileSync(tokenFile, "utf-8")).toBe("opaque-broker-token"); // trimmed + expect(statSync(tokenFile).mode & 0o777).toBe(0o600); + // No Store was opened/seeded — the binding lives on the broker, not here. + expect(existsSync(dbPath)).toBe(false); + // dir still locked to 0700. + expect(statSync(dir).mode & 0o777).toBe(0o700); + }); + + it("rejects an empty token (would silently disable auth)", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-install-")); + const dbPath = join(dir, "collab.db"); + await expect(installToken({ token: " ", dbPath })).rejects.toThrow(/令牌为空/); + expect(existsSync(join(dir, "auth-token"))).toBe(false); + }); +}); diff --git a/src/unit-test/cli-room.test.ts b/src/unit-test/cli-room.test.ts index bf501ee..0edec9d 100644 --- a/src/unit-test/cli-room.test.ts +++ b/src/unit-test/cli-room.test.ts @@ -2,18 +2,24 @@ import { afterEach, describe, expect, it } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createRoom, joinRoom, listRooms } from "../cli/room"; +import { addRoomMember, createRoom, inviteRoomMember, isLoopbackBrokerUrl, joinRoom, listRooms, removeRoomMember, runRoom } from "../cli/room"; import { IdentityService } from "../backbone/identity-service"; import { RoomService } from "../room-service"; +import { StorePskIdentityProvider } from "../backbone/identity/store-psk-identity-provider"; import { SqliteStore } from "../backbone/store/sqlite-store"; import { atomicWriteText } from "../atomic-json"; /** Mimic `abg auth login`: register an identity, issue a token, persist it. */ -async function seedLogin(dir: string, dbPath: string): Promise { +async function seedLogin( + dir: string, + dbPath: string, + id = "alice@x.com", + name = "Alice", +): Promise { const store = new SqliteStore(dbPath); try { const svc = new IdentityService(store); - const identity = await svc.registerIdentity("alice@x.com", "Alice"); + const identity = await svc.registerIdentity(id, name); const token = await svc.issueToken(identity.id); atomicWriteText(join(dir, "auth-token"), token, { mode: 0o600 }); return identity.id; @@ -46,7 +52,7 @@ describe("cli/room", () => { cwd = mkdtempSync(join(tmpdir(), "agentbridge-room-cwd-")); const joined = await joinRoom({ roomId: created.roomId, cwd, dbPath }); - expect(joined).toEqual({ roomId: "my-checkout", agentId: identityId }); + expect(joined).toEqual({ roomId: "my-checkout", agentId: identityId, local: true }); // membership + cwd→room map persisted under a fresh service over the same DB const store = new SqliteStore(dbPath); @@ -99,4 +105,188 @@ describe("cli/room", () => { } await expect(joinRoom({ roomId: "x", dbPath })).rejects.toThrow(/abg auth login/); }); + + it("relaxes join for a REMOTE room (no local record): maps cwd, grants no membership", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-room-")); + const dbPath = join(dir, "collab.db"); + // EDGE machine: NO local identity / room / auth-token at all. Remote join must STILL + // succeed (the remote branch resolves no local identity — it doesn't read auth-token — + // and the broker validates membership at subscribe). Writing no token proves that. + cwd = mkdtempSync(join(tmpdir(), "agentbridge-room-cwd-")); + + // The room does not exist locally → no throw, no identity resolution required. + const result = await joinRoom({ roomId: "remote-room", cwd, dbPath }); + expect(result).toEqual({ roomId: "remote-room", agentId: null, local: false }); + + const store = new SqliteStore(dbPath); + try { + const svc = new RoomService(store); + // cwd was mapped (routing intent) … + expect(await svc.resolveRoomForCwd(cwd)).toBe("remote-room"); + // … but NO membership was granted (broker stays authoritative). + expect(await svc.getMembers("remote-room")).toEqual([]); + } finally { + await store.close(); + } + }); + + it("invite (CLI): prints the 3 onboarding commands + the env-persist note", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-room-")); + const dbPath = join(dir, "collab.db"); + await seedLogin(dir, dbPath, "alice@x.com", "Alice"); // alice logged in (writes auth-token) + await createRoom({ name: "ship-it", cwd: dir, dbPath }); // alice creates → is a member, so she may invite + const prevDb = process.env.AGENTBRIDGE_COLLAB_DB; + process.env.AGENTBRIDGE_COLLAB_DB = dbPath; // runRoom resolves the db via env + const logs: string[] = []; + const origLog = console.log; + console.log = (...a: unknown[]) => void logs.push(a.map(String).join(" ")); + try { + await runRoom(["invite", "ship-it", "bob@x.com", "--broker-url", "ws://100.90.1.42:4700/ws"]); + } finally { + console.log = origLog; + if (prevDb === undefined) delete process.env.AGENTBRIDGE_COLLAB_DB; + else process.env.AGENTBRIDGE_COLLAB_DB = prevDb; + } + const out = logs.join("\n"); + expect(out).toContain("export AGENTBRIDGE_BROKER_URL=ws://100.90.1.42:4700/ws"); + expect(out).toContain("abg auth login --token "); // invitee installs the issued token + expect(out).toContain("abg join ship-it"); + expect(out).toContain("~/.zshrc"); // the env-persist note (MEDIUM fix) must be present + }); + + it("invite: member issues a broker-verifiable token + grants membership", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-room-")); + const dbPath = join(dir, "collab.db"); + const callerId = await seedLogin(dir, dbPath); + await createRoom({ name: "Ship It", dbPath }); // caller is the creator → member + + const { token, brokerUrl } = await inviteRoomMember({ + roomId: "ship-it", + identityId: "bob@x.com", + name: "Bob", + dbPath, + }); + + expect(token).toBeTruthy(); + expect(brokerUrl).toMatch(/^wss?:\/\//); + expect(token).not.toBe(callerId); // sanity: it's a token, not the caller id + + const store = new SqliteStore(dbPath); + try { + // invitee is now a member … + expect(await new RoomService(store).isMember("ship-it", "bob@x.com")).toBe(true); + // … and the issued token authenticates against the broker's provider as the invitee. + const identity = await new StorePskIdentityProvider(store).authenticate(token); + expect(identity).toEqual({ id: "bob@x.com", displayName: "Bob" }); + } finally { + await store.close(); + } + }); + + it("invite: a NON-member caller is rejected (only insiders can invite)", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-room-")); + const dbPath = join(dir, "collab.db"); + await seedLogin(dir, dbPath); // caller = alice, logged in but NOT a member of the target room + + // Create a room owned by someone else (alice is not a member of it). + const store = new SqliteStore(dbPath); + try { + await new RoomService(store).createRoom("secret", "Secret", "owner@x.com"); + } finally { + await store.close(); + } + + await expect( + inviteRoomMember({ roomId: "secret", identityId: "mallory@x.com", dbPath }), + ).rejects.toThrow(/只有房间成员能邀请/); + }); + + it("invite: preserves an existing display name when --name is omitted", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-room-")); + const dbPath = join(dir, "collab.db"); + await seedLogin(dir, dbPath); + await createRoom({ name: "Room", dbPath }); + + // Pre-register the invitee with a real name. + const setup = new SqliteStore(dbPath); + try { + await new IdentityService(setup).registerIdentity("carol@x.com", "Carol Original"); + } finally { + await setup.close(); + } + + await inviteRoomMember({ roomId: "room", identityId: "carol@x.com", dbPath }); // no --name + + const store = new SqliteStore(dbPath); + try { + const id = await new IdentityService(store).getIdentity("carol@x.com"); + expect(id?.displayName).toBe("Carol Original"); // not clobbered with the id + } finally { + await store.close(); + } + + // addRoomMember stays importable + usable (no regression to the existing add path). + expect(typeof addRoomMember).toBe("function"); + }); + + it("invite: --broker-url overrides the loopback default so the URL is reachable", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-room-")); + const dbPath = join(dir, "collab.db"); + await seedLogin(dir, dbPath); + await createRoom({ name: "Net", dbPath }); + + const routable = "ws://100.90.1.42:4700/ws"; + const { brokerUrl } = await inviteRoomMember({ + roomId: "net", + identityId: "dave@x.com", + brokerUrl: routable, + dbPath, + }); + expect(brokerUrl).toBe(routable); + expect(isLoopbackBrokerUrl(brokerUrl)).toBe(false); + }); + + it("remove: a member removes another member", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-room-")); + const dbPath = join(dir, "collab.db"); + await seedLogin(dir, dbPath); // alice = creator → member, may add/remove + await createRoom({ name: "Crew", dbPath }); + await addRoomMember({ roomId: "crew", identityId: "bob@x.com", dbPath }); + + const before = new SqliteStore(dbPath); + try { + expect(await new RoomService(before).isMember("crew", "bob@x.com")).toBe(true); + } finally { + await before.close(); + } + + await removeRoomMember({ roomId: "crew", identityId: "bob@x.com", dbPath }); + + const after = new SqliteStore(dbPath); + try { + expect(await new RoomService(after).isMember("crew", "bob@x.com")).toBe(false); + } finally { + await after.close(); + } + }); + + it("remove: rejects a non-existent room (the existence guard)", async () => { + dir = mkdtempSync(join(tmpdir(), "agentbridge-room-")); + const dbPath = join(dir, "collab.db"); + await seedLogin(dir, dbPath); + await expect( + removeRoomMember({ roomId: "ghost", identityId: "bob@x.com", dbPath }), + ).rejects.toThrow(/房间不存在/); + }); +}); + +describe("isLoopbackBrokerUrl", () => { + it("flags loopback hosts, accepts routable ones", () => { + expect(isLoopbackBrokerUrl("ws://127.0.0.1:4700/ws")).toBe(true); + expect(isLoopbackBrokerUrl("ws://localhost:4700/ws")).toBe(true); + expect(isLoopbackBrokerUrl("ws://[::1]:4700/ws")).toBe(true); + expect(isLoopbackBrokerUrl("ws://100.90.1.42:4700/ws")).toBe(false); + expect(isLoopbackBrokerUrl("ws://192.168.1.5:4700/ws")).toBe(false); + expect(isLoopbackBrokerUrl("wss://broker.example.com/ws")).toBe(false); + }); }); diff --git a/src/unit-test/net-detect.test.ts b/src/unit-test/net-detect.test.ts index d8961a1..6f24e2f 100644 --- a/src/unit-test/net-detect.test.ts +++ b/src/unit-test/net-detect.test.ts @@ -44,9 +44,12 @@ describe("buildConnectionCard", () => { expect(card.primary).toBe("100.90.1.42"); // 命令块含 Tailscale IP expect(card.lines.some((l) => l.includes("ws://100.90.1.42:4700/ws"))).toBe(true); - // 协作者命令块 - expect(card.lines.some((l) => l.includes("abg auth login --token"))).toBe(true); - expect(card.lines.some((l) => l.includes("abg join "))).toBe(true); + // 协作者命令块:invite 命令把可路由地址内嵌进 --broker-url(一条龙可复制) + expect( + card.lines.some((l) => l.includes("abg room invite <对方id> --broker-url ws://100.90.1.42:4700/ws")), + ).toBe(true); + expect(card.lines.some((l) => l.includes("abg room invite"))).toBe(true); + expect(card.lines.some((l) => l.includes("abg room invite "))).toBe(true); // tailscale running → 不显示「没装 Tailscale」指引 expect(card.lines.some((l) => l.includes("没装 Tailscale"))).toBe(false); }); @@ -68,7 +71,7 @@ describe("buildConnectionCard", () => { expect(card.primary).toBe("192.168.0.100"); expect(card.lines.some((l) => l.includes("ws://192.168.0.100:4700/ws"))).toBe(true); - expect(card.lines.some((l) => l.includes("abg auth login --token"))).toBe(true); + expect(card.lines.some((l) => l.includes("abg room invite"))).toBe(true); // tailscale 未运行 → 含「没装 Tailscale」指引 expect(card.lines.some((l) => l.includes("没装 Tailscale"))).toBe(true); expect(card.lines.some((l) => l.includes("tailscale.com/install.sh"))).toBe(true); @@ -93,8 +96,8 @@ describe("buildConnectionCard", () => { // 含仅本机可达警告 expect(card.lines.some((l) => l.includes("只本机可达"))).toBe(true); // 仍然包含协作者命令块(fallback 地址) - expect(card.lines.some((l) => l.includes("abg auth login --token"))).toBe(true); - expect(card.lines.some((l) => l.includes("abg join "))).toBe(true); + expect(card.lines.some((l) => l.includes("abg room invite"))).toBe(true); + expect(card.lines.some((l) => l.includes("abg room invite "))).toBe(true); // tailscale 未运行 → 含「没装 Tailscale」指引 expect(card.lines.some((l) => l.includes("没装 Tailscale"))).toBe(true); }); @@ -116,7 +119,7 @@ describe("buildConnectionCard", () => { expect(card.primary).toBe("100.90.0.5"); expect(card.lines.some((l) => l.includes("ws://100.90.0.5:4700/ws"))).toBe(true); - expect(card.lines.some((l) => l.includes("abg auth login --token"))).toBe(true); + expect(card.lines.some((l) => l.includes("abg room invite"))).toBe(true); }); }); From 2869fdd42bb093969d42d9244fcfa5692765762d Mon Sep 17 00:00:00 2001 From: "rayson951005@gmail.com" Date: Fri, 26 Jun 2026 21:46:04 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(v3):=20=E8=B7=A8=E6=9C=BA=20onboarding?= =?UTF-8?q?=20=E5=A4=9A=E7=8B=AC=E7=AB=8B=20store=20E2E=20=E7=9C=9F?= =?UTF-8?q?=E6=B5=8B=EF=BC=88T8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 物理隔离的两个 SqliteStore(机器A=broker+operator / 机器B=远程边机),驱动真实 CLI 原语 (inviteRoomMember 在 store A 签发+加成员 → installToken 边机装 token → joinRoom 远程只映射 cwd) + 真实边机入口 startRoomBridge,证明:唯一跨界的是 token 字符串,机器A publish 的完成事件 送达机器B;全程 store B 零房间/零成员、连 token 都解析不了(resolveToken→null),broker(store A) 是唯一权威。负向:边机本地自签 token(store C,不在 broker store A)被 broker 拒——复刻修复前根因。 2 个对抗式 opus reviewer 各自独立跑变异测试(连接前删 store A 里 bob 成员资格 / 换 store A 没签发的假 token → B 收 0 条),均判「诚实证明、非空过」、0 真实 issue。 test(v3): cross-machine onboarding multi-store E2E proof (T8) Two physically separate SqliteStore files driving the real CLI primitives + the real edge entry (startRoomBridge): only the token string crosses; machine B's store holds no room / no membership and cannot even resolve the token (resolveToken→null) — the broker (store A) is the sole authority. A completion published on machine A is delivered to machine B. Negative test reproduces the pre-fix root cause (an edge self-signed token, never issued by the broker's store A, is rejected at 4401). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012rhWKm1VUSnEVxmjpYwNfc --- src/integration-test/xnet-onboarding.test.ts | 188 +++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/integration-test/xnet-onboarding.test.ts diff --git a/src/integration-test/xnet-onboarding.test.ts b/src/integration-test/xnet-onboarding.test.ts new file mode 100644 index 0000000..bf0cad8 --- /dev/null +++ b/src/integration-test/xnet-onboarding.test.ts @@ -0,0 +1,188 @@ +/** + * Cross-machine onboarding — the honest end-to-end proof (V3 §11.1 / gap-fix T8). + * + * The bug this fixes: the broker validates a presented token against ITS OWN Store + * (StorePskIdentityProvider) and membership against ITS OWN Store (getMembers), but + * the legacy `abg auth login` self-signed only in the EDGE's local Store — so an edge + * token never authenticated against a remote broker (4401), and the two Stores were + * physically isolated with zero bridge. + * + * These tests use TWO physically separate SqliteStore files (machine A = the broker + * host + the operator; machine B = a remote edge) and drive the REAL CLI primitives + * and the REAL edge entry point (startRoomBridge) — NOT a shared store, NOT a direct + * BrokerClient shortcut. The only thing that crosses the "machine boundary" is the + * token STRING (carried out-of-band), exactly as a human would copy it. + * + * Positive: A issues+invites bob → bob's token is installed on B's separate store → + * `abg join` (remote) maps only the cwd → the edge connects with the carried token → + * a completion published on A is delivered to B. B's store holds NO room and NO + * membership the whole time — the broker (store A) is the sole authority. + * + * Negative (the pre-fix root cause): a token self-signed in a DIFFERENT store (never + * issued by the broker's store A) is rejected by the broker — proving the broker + * really validates against its own store, which is exactly why `auth issue` (sign IN + * the broker store) is the fix and `auth login` self-sign on the edge was broken. + */ + +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Broker } from "../broker"; +import { BrokerClient } from "../broker-client"; +import { SqliteStore } from "../backbone/store/sqlite-store"; +import { StorePskIdentityProvider } from "../backbone/identity/store-psk-identity-provider"; +import { RoomService } from "../room-service"; +import { readAuthToken } from "../collab-store"; +import { authLogin, installToken } from "../cli/auth"; +import { createRoom, inviteRoomMember, joinRoom } from "../cli/room"; +import { startRoomBridge } from "../room-bridge"; +import { publishCompletion } from "../cli/publish"; + +const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); +async function waitFor(cond: () => boolean, timeoutMs = 4000): Promise { + const start = performance.now(); + while (!cond()) { + if (performance.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await delay(20); + } +} + +/** + * Machine A: the broker host + operator. A temp dir with its OWN collab.db. Operator + * "alice" logs in (self-sign on store A — she IS on the broker machine) and creates + * the room. The broker is backed by store A with member-authz reading fresh (ttl 0). + */ +async function machineA(): Promise<{ + dirA: string; + dbPathA: string; + storeA: SqliteStore; + broker: Broker; + url: string; + ROOM: string; +}> { + const dirA = mkdtempSync(join(tmpdir(), "abg-xnet-A-")); + const dbPathA = join(dirA, "collab.db"); + await authLogin({ id: "alice@x.com", name: "Alice", dbPath: dbPathA }); // operator on the broker machine + const { roomId: ROOM } = await createRoom({ name: "checkout", cwd: dirA, dbPath: dbPathA }); // creator=member, cwd mapped + + const storeA = new SqliteStore(dbPathA); // the broker's long-lived handle on store A + const broker = new Broker({ + store: storeA, + identityProvider: new StorePskIdentityProvider(storeA), + host: "127.0.0.1", + port: 0, + memberCacheTtlMs: 0, // re-read membership from store A on every check (no stale cache) + log: () => {}, + }); + const { port } = broker.start(); + return { dirA, dbPathA, storeA, broker, url: `ws://127.0.0.1:${port}/ws`, ROOM }; +} + +describe("跨机 onboarding 端到端(多独立 store,绝不共享)", () => { + let cleanup: Array<() => void | Promise> = []; + afterEach(async () => { + for (const fn of cleanup.reverse()) await fn(); + cleanup = []; + }); + + test("store A 签发的 token 装到独立 store B → join → 连 broker → B 收到房间事件;B 本地零房间/零成员", async () => { + const A = await machineA(); + cleanup.push(() => A.broker.stop(), () => A.storeA.close(), () => rmSync(A.dirA, { recursive: true, force: true })); + + // Operator on A invites bob: issues a token IN store A + adds bob to the room IN store A. + // This token is the ONLY thing carried out-of-band to machine B. + const invite = await inviteRoomMember({ roomId: A.ROOM, identityId: "bob@x.com", name: "Bob", dbPath: A.dbPathA }); + const bobToken = invite.token; + expect(bobToken).toBeTruthy(); + + // ---- Machine B: a SEPARATE, initially-empty store. Only the token string crossed. ---- + const dirB = mkdtempSync(join(tmpdir(), "abg-xnet-B-")); + const dbPathB = join(dirB, "collab.db"); + cleanup.push(() => rmSync(dirB, { recursive: true, force: true })); + + await installToken({ token: bobToken, dbPath: dbPathB }); // = `abg auth login --token ` + const joined = await joinRoom({ roomId: A.ROOM, cwd: dirB, dbPath: dbPathB }); // = `abg join` (remote) + expect(joined).toEqual({ roomId: A.ROOM, agentId: null, local: false }); + + // HONESTY: the two stores are physically separate files; B has NO room and NO + // membership — only a cwd→room routing hint and the carried token file. + expect(dbPathB).not.toBe(A.dbPathA); + const sB = new SqliteStore(dbPathB); + try { + expect(await new RoomService(sB).getRoom(A.ROOM)).toBeNull(); // room was NEVER created on B + expect(await new RoomService(sB).getMembers(A.ROOM)).toEqual([]); // bob is NOT a local member on B + expect(await new RoomService(sB).resolveRoomForCwd(dirB)).toBe(A.ROOM); // just a routing hint + expect(await sB.resolveToken(bobToken)).toBeNull(); // B's store can't even resolve the token — only store A holds the binding + } finally { + await sB.close(); + } + expect(readAuthToken(dbPathB)).toBe(bobToken); // B's sole credential = the carried token + // Membership lives ONLY in the broker's store A. + expect(await new RoomService(A.storeA).getMembers(A.ROOM)).toContain("bob@x.com"); + + // ---- Machine B edge connects via the REAL entry point: it reads its OWN auth-token, + // resolves the room from its OWN cwd map, and connects with the carried token. ---- + const emitted: string[] = []; + const bridge = await startRoomBridge({ + cwd: dirB, + emit: (t) => emitted.push(t), + dbPath: dbPathB, + brokerUrl: A.url, + log: () => {}, + }); + cleanup.push(() => bridge.stop()); + expect(bridge.roomId).toBe(A.ROOM); // edge went ACTIVE (token present + cwd mapped), not inert + await delay(250); // let the connection + subscribe land + + // ---- Machine A publishes a completion to the room ---- + const res = await publishCompletion({ + store: A.storeA, + dbPath: A.dbPathA, + cwd: A.dirA, + brokerUrl: A.url, + argv: ["--summary", "跨机契约 auth/v1", "--repo", "app", "--unblocks", "bob@x.com"], + }); + expect(res.status).toBe("published"); + expect(res.roomId).toBe(A.ROOM); + + // ---- B receives it — delivered across two physically separate stores ---- + await waitFor(() => emitted.some((t) => t.includes("跨机契约 auth/v1"))); + const hit = emitted.find((t) => t.includes("跨机契约 auth/v1"))!; + expect(hit).toContain("🏁 完成任务"); + expect(hit).toContain("app"); + }); + + test("边机本地自签的 token(不在 broker store A)被 broker 拒——这正是修复前的根因", async () => { + const A = await machineA(); + cleanup.push(() => A.broker.stop(), () => A.storeA.close(), () => rmSync(A.dirA, { recursive: true, force: true })); + + // Machine C self-signs locally with `abg auth login --id --name`: the (token→identity) + // binding lands in store C, NEVER in the broker's store A. + const dirC = mkdtempSync(join(tmpdir(), "abg-xnet-C-")); + const dbPathC = join(dirC, "collab.db"); + cleanup.push(() => rmSync(dirC, { recursive: true, force: true })); + const self = await authLogin({ id: "bob@x.com", name: "Bob", dbPath: dbPathC }); + + // The self-signed token authenticates against ITS OWN store C … + const sC = new SqliteStore(dbPathC); + try { + expect(await new StorePskIdentityProvider(sC).authenticate(self.token)).toEqual({ + id: "bob@x.com", + displayName: "Bob", + }); + } finally { + await sC.close(); + } + // … but NOT against the broker's store A (it never issued it). + await expect(new StorePskIdentityProvider(A.storeA).authenticate(self.token)).rejects.toThrow(/invalid PSK token/); + + // So the edge connect fails at the broker (the old broken path: self-sign on the edge → 4401). + const c = new BrokerClient({ url: A.url, token: self.token, log: () => {} }); + try { + await expect(c.connect()).rejects.toThrow(); + } finally { + c.close(); + } + }); +});